Ecosystem และ Toolchain
การรัน TypeScript ใน Node.js
หัวข้อที่มีชื่อว่า “การรัน TypeScript ใน Node.js”ตั้งแต่ Node 22.18 — และทุก release ของ Node 24 และ 26 — Node ลบ type ของ TypeScript ให้เอง คุณจะต้องใช้เครื่องมือเพิ่มก็ต่อเมื่อโค้ดใช้ syntax ที่ต้อง แปลง (transform) ไม่ใช่แค่ลบทิ้ง คุณมีตัวเลือกหลักสามตัว:
flowchart TB
Start["I need to run a .ts file"] --> Q1{"Only type annotations, interfaces, import type?"}
Q1 -->|"yes"| Native["node file.ts -- built-in stripping, no install"]
Q1 -->|"no -- enum, decorators, parameter properties"| Q2{"Node 26, or --experimental-transform-types?"}
Q2 -->|"yes"| Native
Q2 -->|"no"| Q3{"Need fast local dev loop?"}
Q3 -->|"yes"| Tsx["tsx -- esbuild-based, milliseconds to start"]
Q3 -->|"no, need mature config/plugins"| TsNode["ts-node"] ตัวเลือกที่ 1: type stripping ในตัว (Node 22.18+ / 24 — ไม่ต้องใส่ flag)
หัวข้อที่มีชื่อว่า “ตัวเลือกที่ 1: type stripping ในตัว (Node 22.18+ / 24 — ไม่ต้องใส่ flag)”Node ลบ type annotations ตอนโหลดไฟล์แล้วรัน JavaScript ที่เหลือ ไม่ต้องติดตั้ง ไม่ต้องตั้งค่า — แต่ ไม่ได้ตรวจสอบ type และไม่อ่าน tsconfig.json:
# Node 22.18+ / 24 / 26 — just run the filenode src/app.ts
# Older 22.x releases needed the flagnode --experimental-strip-types src/app.ts
# Keep type-checking as a separate step (CI, pre-commit)npx tsc --noEmitรองรับเฉพาะ syntax ที่ ลบทิ้งได้ (erasable): type annotations, interface, type alias, import type ส่วน syntax ที่ต้อง generate JavaScript — enum, namespace ที่มีโค้ด runtime, parameter properties อย่าง constructor(private x: number), import = — จะถูกปฏิเสธ Node 24 แปลงพวกนี้ให้ได้ถ้าใส่ --experimental-transform-types ส่วนใน Node 26 การแปลงถูกรวมเข้ามาในตัวและ flag นี้ถูกถอดออกแล้ว option --erasableSyntaxOnly ของ TypeScript (5.8+) ทำให้ tsc ปฏิเสธ syntax ชุดเดียวกัน editor ของคุณจึงเตือนก่อนที่ Node จะเตือน
ยืนยัน “เปิดเป็นค่าเริ่มต้น” แทนที่จะเชื่อชื่อ flag
หัวข้อที่มีชื่อว่า “ยืนยัน “เปิดเป็นค่าเริ่มต้น” แทนที่จะเชื่อชื่อ flag”flag นี้ยังชื่อ --experimental-strip-types ใน node --help บน Node 24 — ชื่อนั้นอย่างเดียวอาจทำให้คิดว่าต้อง opt-in เอง การเช็กว่าจริง ๆ แล้วเกิดอะไรขึ้นจะให้คำตอบที่แน่ชัด:
node --help | grep -i strip --experimental-strip-types, --no-strip-types Type-stripping for TypeScript files.--no-strip-types ที่จับคู่มาด้วยคือตัวบอกใบ้: --no- counterpart จะมีอยู่ก็ต่อเมื่อสิ่งนั้นเปิดอยู่เป็นค่าเริ่มต้นเท่านั้น ยืนยันได้ด้วยการรันไฟล์ .ts จริง ๆ ทั้งสองแบบ:
node strip.ts # runs fine, prints the computed value, no flag needednode --no-experimental-strip-types strip.ts # SyntaxError: Unexpected identifier 'Point'# node strip.ts3
# node --no-experimental-strip-types strip.tsSyntaxError: Unexpected identifier 'Point'Type stripping เปิดเป็นค่าเริ่มต้นบน Node 24 สำหรับไฟล์ .ts ธรรมดาที่มีแค่ syntax แบบ erasable — ไม่ต้องใส่ flag และไม่มี ExperimentalWarning พิมพ์ออกมาด้วย prefix --experimental- ในชื่อ flag เป็นของที่หลงเหลือจากตอนที่มันยังต้อง opt-in การใส่ --no-experimental-strip-types (หรือ --no-strip-types) อย่างชัดเจนเป็นวิธีเดียวที่จะปิดมันกลับ
ตัวเลือกที่ 2: tsx — TypeScript runner ที่เร็ว
หัวข้อที่มีชื่อว่า “ตัวเลือกที่ 2: tsx — TypeScript runner ที่เร็ว”tsx เป็น wrapper เบา ๆ รอบ esbuild รองรับ TypeScript ทุกฟีเจอร์และเริ่มในเวลามิลลิวินาที:
# Run oncenpx tsx src/app.ts
# Watch mode — auto-restart on changesnpx tsx watch src/app.ts
# Install as a dev dependencynpm install --save-dev tsxตัวเลือกที่ 3: ts-node — ตัวคลาสสิก
หัวข้อที่มีชื่อว่า “ตัวเลือกที่ 3: ts-node — ตัวคลาสสิก”ts-node เป็นมาตรฐานมาหลายปี หนักกว่า tsx แต่มี configuration support ที่ครบครัน:
npm install --save-dev ts-node typescript
# Run a filenpx ts-node src/app.ts
# ESM mode (tsconfig must have "module": "NodeNext")npx ts-node --esm src/app.tsLinters และ Formatters
หัวข้อที่มีชื่อว่า “Linters และ Formatters”ESLint — ตรวจจับ logic errors และบังคับใช้ style rules
หัวข้อที่มีชื่อว่า “ESLint — ตรวจจับ logic errors และบังคับใช้ style rules”# Install ESLint with TypeScript supportnpm install --save-dev eslint @eslint/js typescript-eslint
# Create a flat config (eslint.config.mjs)# then run:npx eslint src/
# Auto-fix fixable issuesnpx eslint --fix src/eslint.config.mjs พื้นฐานสำหรับ TypeScript:
// @skip-verify CLI/flag: needs @eslint/js and typescript-eslint installed as devDependencies — not part of this repoimport js from '@eslint/js';import tseslint from 'typescript-eslint';
export default tseslint.config( js.configs.recommended, ...tseslint.configs.recommended,);eslint-plugin-n — rule เฉพาะของ Node ที่ ESLint เองไม่รู้จัก
หัวข้อที่มีชื่อว่า “eslint-plugin-n — rule เฉพาะของ Node ที่ ESLint เองไม่รู้จัก”eslint-plugin-n (เดิมชื่อ eslint-plugin-node) เพิ่ม rule ที่ตรวจกับ runtime และ API surface จริงของ Node — สิ่งที่ ESLint ธรรมดาไม่มีทางรู้ได้เอง การรันมันกับไฟล์ที่มี bug ตั้งใจใส่ไว้แสดงให้เห็นชัดว่ามันจับอะไรได้บ้าง:
// @skip-verify CLI/flag: needs eslint-plugin-n installed as a devDependency — not part of this repoimport n from 'eslint-plugin-n';
export default [ n.configs['flat/recommended'],];npx eslint bad.jsbad.js 2:24 error Can't resolve './does-not-exist.js' in '/project' n/no-missing-require 3:13 error 'new Buffer()' was deprecated since v6.0.0. Use 'Buffer.alloc()' or 'Buffer.from()' instead n/no-deprecated-api 15:1 error Don't use process.exit(); throw an error instead n/no-process-exit
✖ 3 problems (3 errors, 0 warnings)สามข้อค้นพบเฉพาะของ Node ที่ ESLint config ทั่วไปจะพลาดไปเลย: n/no-missing-require จับ require() ของไฟล์ที่ไม่มีอยู่จริงบนดิสก์ n/no-deprecated-api ตรวจ runtime API ที่ Node เอง deprecate ไว้ (เช่น new Buffer()) และ n/no-process-exit ตรวจการเรียก process.exit() นอกจุด entry point ของ CLI จริง ๆ (มันข้ามกรณีที่ return เงียบ ๆ จากฟังก์ชัน ซึ่งมักซ่อน bug มากกว่าจะแก้)
Prettier — code formatter แบบ opinionated
หัวข้อที่มีชื่อว่า “Prettier — code formatter แบบ opinionated”npm install --save-dev prettier
# Format all filesnpx prettier --write .
# Check formatting without changing files (for CI)npx prettier --check .Prettier และ ESLint มุ่งเน้นคนละด้าน: Prettier จัดการ formatting (ช่องว่าง, quotes, semicolons) ส่วน ESLint จัดการ logic และ style rules รันทั้งคู่ตามลำดับหรือใช้ eslint-config-prettier เพื่อปิด formatting rules ของ ESLint
Biome — เครื่องมือเดียวสำหรับทั้ง lint และ format
หัวข้อที่มีชื่อว่า “Biome — เครื่องมือเดียวสำหรับทั้ง lint และ format”Biome แทนที่ ESLint + Prettier ด้วยเครื่องมือเดียวที่เร็วเขียนด้วย Rust:
npm install --save-dev @biomejs/biomenpx biome init
# Lint and format in one passnpx biome check --apply src/Watch Mode และ nodemon
หัวข้อที่มีชื่อว่า “Watch Mode และ nodemon”Node built-in watch (stable ตั้งแต่ Node 22)
หัวข้อที่มีชื่อว่า “Node built-in watch (stable ตั้งแต่ Node 22)”# Restart on any file change — no install requirednode --watch src/app.js
# Watch specific filesnode --watch-path=src src/app.jsnodemon — watcher ที่ผ่านการพิสูจน์แล้ว
หัวข้อที่มีชื่อว่า “nodemon — watcher ที่ผ่านการพิสูจน์แล้ว”npm install --save-dev nodemon
# Basic usagenpx nodemon src/app.js
# With TypeScript via tsxnpx nodemon --exec "npx tsx" src/app.ts
# nodemon.json config{ "watch": ["src"], "ext": "ts,js,json", "exec": "npx tsx src/app.ts"}Environment Variables และ .env Files
หัวข้อที่มีชื่อว่า “Environment Variables และ .env Files”Native .env loading (stable ตั้งแต่ 22.21 / 24.10)
หัวข้อที่มีชื่อว่า “Native .env loading (stable ตั้งแต่ 22.21 / 24.10)”# Load a .env file without dotenvnode --env-file=.env src/app.js
# Load multiple env files (later files override earlier)node --env-file=.env --env-file=.env.local src/app.jsการรองรับ .env ผ่านช่วง experimental มาก่อน — เริ่มมาพร้อม warning ตั้งแต่ 20.6 แล้วค่อย stable ที่ 22.21 และ 24.10 (ไม่มี ExperimentalWarning ตั้งแต่เวอร์ชันนั้นเป็นต้นไป)
ภายในโปรแกรม process.loadEnvFile() ทำแบบเดียวกันโดยไม่ต้องใส่ flag:
Output (verified on Node 24)
hello-from-dotenvdotenv — วิธีคลาสสิก
หัวข้อที่มีชื่อว่า “dotenv — วิธีคลาสสิก”npm install dotenv// @skip-verify CLI/flag: needs the dotenv package installed — not part of this repo// src/app.js — load at the very topimport 'dotenv/config';
console.log(process.env.DATABASE_URL); // from .envbest practices สำหรับ env files
หัวข้อที่มีชื่อว่า “best practices สำหรับ env files”# .env — default values, committed to git (no secrets)NODE_ENV=developmentPORT=3000LOG_LEVEL=info
# .env.local — local overrides, NOT committed (in .gitignore)DATABASE_URL=postgres://localhost:5432/mydbSECRET_KEY=dev-only-secret
# .env.example — template showing required vars, committedDATABASE_URL=SECRET_KEY=เพิ่ม .env.local และ .env*.local ใน .gitignore เสมอ ห้าม commit secrets จริง ๆ
node —run และ permission model
หัวข้อที่มีชื่อว่า “node —run และ permission model”node --run <script> รัน script ใน package.json โดยไม่ผ่าน npm — เริ่มเร็วกว่าและไม่ inject environment ของ npm เข้ามา:
node --run dev # same as `npm run dev`, minus npm's startup costnode --run testสำหรับ script ที่รันโค้ดที่คุณไม่ไว้ใจเต็มที่ Node 24 ทำให้ชื่อ flag ของ permission model นิ่งแล้ว มันปฏิเสธการเข้าถึง file system, child process และ worker ทั้งหมด จนกว่าคุณจะอนุญาตอย่างชัดเจน:
node --permission --allow-fs-read=./data --allow-fs-write=./tmp src/app.jscorepack และ pnpm
หัวข้อที่มีชื่อว่า “corepack และ pnpm”Corepack มาพร้อมกับ Node เอง (ยืนยันตรงนี้: corepack --version พิมพ์ 0.34.5 บน Node 24.13 ของคอร์สนี้ ไม่ต้องติดตั้งแยก) และจัดการเวอร์ชันของ package manager ที่โปรเจกต์คุณประกาศไว้ เหมือนกับที่ nvm/.nvmrc จัดการเวอร์ชันของ Node:
# Pin the package manager version in package.json# "packageManager": "[email protected]"
corepack enable # one-time, makes `pnpm`/`yarn` resolve through corepackpnpm install # corepack downloads and runs the exact pinned pnpm versionpnpm เองใช้ content-addressable store และ hard link แทนการ copy node_modules ในแต่ละโปรเจกต์ — หลายโปรเจกต์บนเครื่องเดียวกันแชร์ package เวอร์ชันเดียวกันแค่ชุดเดียวบนดิสก์ และการติดตั้งมักเร็วกว่า npm นอกจากนี้มันยังบังคับการ resolve dependency ที่เข้มงวดกว่าเป็นค่าเริ่มต้น (package หนึ่งจะ require() สิ่งที่มันไม่ได้ประกาศเป็น dependency ของตัวเองไม่ได้ แม้ package อื่นจะ hoist มันขึ้นมาใน node_modules ก็ตาม) ซึ่งเผย bug แบบ “phantom dependency” ที่ node_modules แบบแบนของ npm มักซ่อนไว้
Docker: image ของ Node ที่เหมาะกับ production
หัวข้อที่มีชื่อว่า “Docker: image ของ Node ที่เหมาะกับ production”FROM node:24-slim
WORKDIR /app
# Only copy manifests first -- layer caching skips the install step# when only application code changed, not dependencies.COPY package.json package-lock.json ./RUN npm ci --omit=dev
COPY . .
# node:*-slim images already ship a non-root "node" user -- use itUSER node
# Exec form (not shell form) so SIGTERM reaches the Node process directly,# not a shell wrapping it -- required for graceful shutdown to actually run.CMD ["node", "src/server.js"]รายละเอียดสามอย่างที่แยก image ที่เหมาะกับ production ออกจาก image ที่แค่ทำงานได้: npm ci --omit=dev (ไม่ใช่ npm install) — reproducible จาก lockfile และข้าม devDependencies ไปเลย; USER node — รันด้วย user ที่ไม่มีสิทธิ์ root ที่มากับ image แทนที่จะรันเป็น root; และ CMD แบบ JSON array (exec form) — CMD แบบ shell (CMD node src/server.js) รัน Node เป็น child ของ /bin/sh และ SIGTERM จาก docker stop/Kubernetes จะไปที่ shell ไม่ใช่ Node ทำให้ process.on('SIGTERM', ...) handler ของคุณไม่เคยถูกเรียกเลย และ orchestrator ต้องรอจนหมด grace period ก่อนจะ SIGKILL
Structured logging แทน console.log
หัวข้อที่มีชื่อว่า “Structured logging แทน console.log”console.log ใช้ได้ดีสำหรับ script ที่รันบน terminal ของคุณเอง แต่ในบริการที่รันใน container หลัง log aggregator ข้อความ plain text จะเสียความสามารถในการ filter หรือ query ตาม field ไป structured logger (pino เป็นตัวเลือกที่เร็วที่สุดที่นิยมใน Node ecosystem) เขียน JSON object หนึ่งบรรทัดต่อหนึ่ง log แทน:
npm install pino// @skip-verify CLI/flag: needs the pino package installed — not part of this repoimport pino from 'pino';
const logger = pino();
logger.info({ userId: 42, action: 'login' }, 'user logged in');// {"level":30,"time":...,"userId":42,"action":"login","msg":"user logged in"}log แต่ละบรรทัดกลายเป็น record ที่ query ได้ (filter ตาม userId, alert เมื่อ level >= 50) แทนที่จะเป็น string ที่มนุษย์ต้อง grep เอง pino ถูกออกแบบมาให้มี overhead ต่อการเรียกน้อยที่สุด ซึ่งสำคัญบน request path ที่ร้อนแรงที่แม้แต่ console.log เองก็อาจกลายเป็นต้นทุนที่วัดได้
package.json scripts — ตัวเชื่อมทุกอย่าง
หัวข้อที่มีชื่อว่า “package.json scripts — ตัวเชื่อมทุกอย่าง”{ "scripts": { "dev": "tsx watch src/app.ts", "build": "tsc --noEmit", "start": "node dist/app.js", "test": "node --test", "lint": "eslint src/ && prettier --check .", "format": "prettier --write . && eslint --fix src/" }}แบบจำลองความคิด (Mental Model)
หัวข้อที่มีชื่อว่า “แบบจำลองความคิด (Mental Model)”ทุกอย่างในบทนี้เป็น layer ที่คุณควรเพิ่มก็ต่อเมื่อยืนยันแล้วว่าพฤติกรรม built-in ของ Node ยังไม่ครอบคลุมมันอยู่แล้ว: type stripping ก่อนจะไปหา tsx, --env-file/--watch ก่อนจะไปหา dotenv/nodemon, node --run ก่อนจะสันนิษฐานว่าต้องใช้ npm, coverage ในตัวของ node:test ก่อนจะไปหา reporter จาก third-party รูปแบบนี้เกิดซ้ำเพราะ built-in ของ Node ค่อย ๆ ดูดซับสิ่งที่เมื่อก่อนต้องพึ่ง dependency ไปเรื่อย ๆ — การเช็กว่า “flag นี้มี --no- counterpart ไหม” หรือ “--help พูดถึงมันจริงไหม” เป็นวิธีที่เร็วกว่าการสันนิษฐานว่าคำตอบเมื่อปีที่แล้วยังใช้ได้อยู่
Trade-offs
หัวข้อที่มีชื่อว่า “Trade-offs”| ตัวเลือก | ข้อดี | ต้นทุน |
|---|---|---|
| Type stripping ในตัว | ไม่ต้องติดตั้ง ไม่ต้อง config ทำงานทันทีที่รันไฟล์ | ไม่มีการตรวจ type ไม่รองรับ enum/decorator/parameter property โดยไม่ใส่ flag เพิ่ม |
tsx/ts-node | รองรับฟีเจอร์ TypeScript ครบ ecosystem ที่เติบโตแล้ว | ต้องมี dependency ให้ติดตั้ง pin และคอยอัปเดต |
npm ci --omit=dev ใน Docker | build ซ้ำได้จาก lockfile, image เล็กลง, ติดตั้งเร็วกว่า | ต้องมี lockfile ที่ commit ไว้และอัปเดตอยู่เสมอ — npm ci fail ทันทีถ้า package.json กับ lockfile ไม่ตรงกัน |
pino/structured logging | log query ได้ filter ได้ overhead ต่อการเรียกต่ำกว่า console.log บน path ที่ร้อนแรง | output ไม่ได้อ่านง่ายตรง ๆ ใน terminal อีกต่อไปถ้าไม่มี formatter (pino-pretty ตอน dev) |
ความเข้าใจผิดที่พบบ่อย
หัวข้อที่มีชื่อว่า “ความเข้าใจผิดที่พบบ่อย”- “TypeScript บน Node ในปี 2026 ยังต้องใช้ tsx หรือ ts-node อยู่ดี” — ความจริง: บน Node 22.18+/24/26 syntax แบบ type annotation ธรรมดารันได้โดยไม่ต้องมีเครื่องมือเพิ่มเลย ต้องใช้ transformer เฉพาะ
enum/decorator/parameter property เท่านั้น (หรือใช้--experimental-transform-typesบน 24) - “prefix —experimental- ใน —experimental-strip-types แปลว่าต้อง opt-in เอง” — ความจริง: ชื่อ ของ flag เป็นของหลงเหลือ ตัวพฤติกรรมจริงเปิดเป็นค่าเริ่มต้นบน Node 24 ยืนยันได้จาก
--no-strip-typesที่มีไว้เพื่อปิดมัน ไม่ใช่เปิด - “npm install กับ npm ci ทำสิ่งเดียวกัน แค่ชื่อต่างกัน” — ความจริง:
npm ciต้องมี lockfile ที่มีอยู่แล้วและตรงกัน ถ้าไม่ตรงมัน fail มันลบnode_modulesก่อนแล้วติดตั้งตามที่ lock ไว้เป๊ะ ๆ — ความ reproducible ที่ Docker build ต้องการ ซึ่งnpm installไม่รับประกันให้ - “CMD แบบ shell กับ exec-form JSON array ใน Dockerfile ทำงานเหมือนกัน” — ความจริง: shell form รัน process ของคุณเป็น child ของ
/bin/shดังนั้นSIGTERMจากdocker stopจะไปโดน shell ไม่ใช่ Node process ของคุณ —SIGTERMhandler ของคุณไม่เคยถูกเรียกเลย
อ่านโค้ดที่ AI เขียน
หัวข้อที่มีชื่อว่า “อ่านโค้ดที่ AI เขียน”- โปรเจกต์ที่ถูกสร้างขึ้นเพิ่ม
ts-node,nodemon, หรือdotenvเป็น dependency โดยไม่เช็กก่อนว่า built-in ของ Node เวอร์ชันเป้าหมาย (type stripping,--watch,--env-file) ครอบคลุมความต้องการเดียวกันแล้วหรือไม่? - Dockerfile ใช้
npm installแทนnpm ciในขั้น build ที่ควรจะ reproducible หรือใช้CMDแบบ shell (CMD node server.js) แทน exec-form JSON array หรือไม่? - Dockerfile ข้าม
USER node(หรือ user ที่ไม่ใช่ root ที่เทียบเท่า) ทำให้ container รันเป็น root ตลอดหรือไม่? - ใช้
console.logทั่วทั้ง service ที่ตั้งใจรันใน production โดยไม่มี structured logger เลย ทำให้ query field ของ log ไม่ได้เมื่อระบบโตขึ้นหรือไม่?
🐛 หา bug ในโค้ดนี้
FROM node:24-slim
WORKDIR /appCOPY . .RUN npm install
CMD node src/server.jsเฉลย
มีปัญหาแยกกันสองอย่างตรงนี้ อย่างแรก npm install (ไม่ใช่ npm ci) อาจอัปเดตเวอร์ชัน dependency ให้ต่างจาก lockfile แบบเงียบ ๆ ระหว่าง build ของ Docker — image ที่คุณ build วันนี้อาจไม่ตรงกับ image ที่ build พรุ่งนี้จาก source เดียวกัน และไม่มีบรรทัด USER node เลย container จึงรันเป็น root ตลอดเวลา อย่างที่สอง CMD node src/server.js เป็น shell form — Docker รันมันเป็น /bin/sh -c "node src/server.js" ทำให้ node เป็น child process ของ shell เมื่อ docker stop ส่ง SIGTERM มันจะไปที่ shell (PID 1) ไม่ใช่ node และ shell ไม่ forward สัญญาณนั้นให้เป็นค่าเริ่มต้น — ดังนั้น process.on('SIGTERM', ...) handler สำหรับ graceful shutdown ในแอปจึงไม่เคยถูกเรียกเลย และ container จะค้างอยู่จนกว่า grace period ของ Docker จะหมดแล้วส่ง SIGKILL ทางแก้: RUN npm ci --omit=dev, เพิ่ม USER node ก่อน CMD, และใช้ exec form CMD ["node", "src/server.js"] เพื่อให้ Node รับสัญญาณโดยตรงในฐานะ PID 1