ข้ามไปยังเนื้อหา

The Compiler & Tooling

ทุกอย่างเรื่อง type ที่คุณเรียนมาถูกบังคับใช้โดยโปรแกรมเดียวคือ TypeScript compiler หรือ tsc แต่ tsc ทำงานสองอย่างที่ควรแยกออกจากกันในหัว — tsc check type ของคุณ และ emit JavaScript ออกมา setup สมัยใหม่มักแยกสองงานนี้ออกจากกัน: ให้ bundler emit JavaScript อย่างรวดเร็ว แล้วให้ tsc check type แยกต่างหาก

tsconfig.json คือที่ที่คุณตัดสินใจว่า checker จะเข้มแค่ไหน, จะ target JavaScript feature รุ่นไหน, module ถูก resolve อย่างไร และจะ emit อะไรออกมา (ถ้ามี) การตั้งไฟล์นี้ให้ถูกคือความต่างระหว่าง TypeScript ที่จับ bug ได้จริง กับ TypeScript ที่ปล่อย bug ผ่านไปเงียบ ๆ

โมดูลนี้เขียนตาม TypeScript 7 — compiler ที่เขียนใหม่ด้วย Go (กรกฎาคม 2026) — และ release 6.0 ก่อนหน้าที่พลิก default (strict เปิด, output เป็น ESM, ไม่โหลด @types อัตโนมัติ) และเลิกใช้ option เก่าที่สะสมมาสิบปี ตรงไหนที่บทเรียนพูดถึงพฤติกรรมแบบเก่าจะระบุไว้ชัด

บทเรียนสิ่งที่คุณจะได้เรียนรู้
tsconfig, deepdefault ของ 6/7, สิ่งที่ tsc --init เขียนให้, strict และครอบครัวของตัวเอง และ option ที่ถูกถอดออก
Modules & resolutionESM เทียบ CJS, nodenext / node20 / bundler, import attribute, import defer และทำไม esModuleInterop กลายเป็นอดีต
Declaration files.d.ts คืออะไร, types: [], isolatedDeclarations และ @types มาจากไหน
Build & emitnative compiler และ --checkers, tsc เทียบ bundler, การรัน .ts ใน Node, isolatedModules และ project references
flowchart LR
  src["source .ts / .tsx"] --> tsc["tsc"]
  tsc -->|job 1| check["type-check
report errors"]
  tsc -->|job 2| emit["emit .js and .d.ts
(types erased)"]
  check --> ci["run in CI to catch bugs"]
  emit --> ship["run or bundle the JS"]
tsc ทำสองงานที่เป็นอิสระต่อกัน

แยกสองงานนี้ให้ชัดแล้วทั้ง toolchain จะเข้าใจง่ายขึ้น bundler อย่าง esbuild ทำงานที่ 2 ได้ในเสี้ยววินาที เพราะแค่ strip type ออกโดยไม่เข้าใจ type เลย — ซึ่งแปลว่าจะ emit code ที่ type-check ไม่ผ่านออกมาได้อย่างสบายใจ นั่นคือเหตุผลที่คำแนะนำมาตรฐานคือ: ให้ bundler emit แล้วรัน tsc --noEmit ใน CI เพื่อ check จริง ๆ ด้วย TypeScript 7 การ check นั้นเร็วกว่าเดิมราวสิบเท่า pattern นี้จึงเสียเวลาแค่หลักวินาที ไม่ใช่นาที ที่เหลือของโมดูลนี้จะแกะ config ที่ควบคุมทั้งสองงานนี้

strict เปิดเสมอทุกที่ที่คอร์สนี้ให้คุณ check type — รวมถึง checker แบบ interactive ด้านล่างนี้ ซึ่ง type-check ด้วย TypeScript compiler จริง (โหลดจาก CDN) ไม่ใช่การจำลอง กด Check types โดยไม่แก้อะไรเลย แล้วคุณจะเห็น noImplicitAny diagnostic จริงสองตัว — bundler ที่ transpile ไฟล์นี้จะ emit JavaScript ที่ดูทำงานได้ออกมาโดยไม่บ่นอะไรเลย เพราะมันไม่เคยดู shape ของ items เลยด้วยซ้ำ

bundler จะ emit อันนี้แบบเงียบ ๆ แต่ tsc --noEmit จะไม่เงียบ

// @expect-error TS7006
function double(items) {
return items.map((n) => n * 2);
}
console.log(double([1, 2, 3]));
Open in TS Playground

นี่คือเวอร์ชันรูปธรรมของ “ให้ bundler emit ให้ tsc check”: double มี parameter สองตัวที่ไม่ได้ประกาศ type และภายใต้ noImplicitAny ของ strict นั่นคือ error ไม่ใช่การเดา bundler-only pipeline จะรัน double(["a", "b"]) ได้สบายพอ ๆ กับ double([1, 2, 3]) — มันไม่มีทางรู้ว่าผิดจนกว่าจะพังตอน runtime

rule ของ ESLint ธรรมดาเห็นแค่ syntax — semicolon หาย, ตัวแปรไม่ได้ใช้ — โดยไม่เคยถาม type checker เลย typed rule ของ typescript-eslint ถาม checker ตรง ๆ ซึ่งเป็นเหตุผลที่มันจับ bug ที่ linter แบบ syntax-only จับไม่ได้: await ที่ลืมใส่, any ที่ leak ผ่านฟังก์ชันสามชั้นเงียบ ๆ, switch ที่ดูครบแต่ไม่ครบจริง การเปิดมันมีต้นทุนจริง ๆ ทั้งเรื่อง setup และเวลา ซึ่งควรเห็นตรง ๆ ก่อนตัดสินใจว่าจะวางเส้นตรงไหน

นี่คือ eslint.config.mjs ที่เปิด typed rule ที่คอร์สนี้สนใจที่สุด โดยใช้ projectService — ตัวตั้งค่าที่ให้ typescript-eslint หา tsconfig.json ของคุณเองอัตโนมัติ แทนที่จะต้องแจกแจง project ของทุกไฟล์เอง:

// @ts-check
import tseslint from "typescript-eslint";
export default tseslint.config(
{
languageOptions: {
parserOptions: {
projectService: true,
tsconfigRootDir: import.meta.dirname,
},
},
},
...tseslint.configs.recommendedTypeChecked,
{
rules: {
"@typescript-eslint/no-floating-promises": "error",
"@typescript-eslint/no-misused-promises": "error",
"@typescript-eslint/no-unsafe-assignment": "error",
"@typescript-eslint/no-unsafe-member-access": "error",
"@typescript-eslint/no-unsafe-call": "error",
"@typescript-eslint/no-explicit-any": "error",
"@typescript-eslint/switch-exhaustiveness-check": "error",
"@typescript-eslint/consistent-type-imports": "error",
},
},
);

รันกับไฟล์เล็ก ๆ ไฟล์เดียวที่ trigger ทุก rule ข้างบน (ตรวจใน scratch จริง — [email protected] บน [email protected] เพราะ typescript-eslint ยังไม่รองรับ TypeScript 7: ติดตั้งคู่กับ typescript@7 แล้ว npm peer-dependency check พังด้วย peer typescript@">=4.8.4 <6.1.0" from [email protected] โปรเจกต์ที่ใช้ TypeScript 7 จึงต้องเก็บ typescript@6 ไว้สำหรับเครื่องมือนี้ pattern เดียวกับ @typescript/typescript6 ที่ Build & Emit พูดถึงสำหรับ language server):

1:1 error All imports in the declaration are only used as types. Use `import type` @typescript-eslint/consistent-type-imports
3:1 error Async function 'fetchUser' has no 'await' expression @typescript-eslint/require-await
12:3 error Promises must be awaited, end with a call to .catch, ... or be explicitly
marked as ignored with the `void` operator @typescript-eslint/no-floating-promises
14:11 error Promise returned in function argument where a void return was expected @typescript-eslint/no-misused-promises
18:9 error Unsafe assignment of an `any` value @typescript-eslint/no-unsafe-assignment
18:15 error Unexpected any. Specify a different type @typescript-eslint/no-explicit-any
19:22 error Unsafe member access .value on an `any` value @typescript-eslint/no-unsafe-member-access
20:15 error Unsafe call of an `any` typed value @typescript-eslint/no-unsafe-call
✖ 13 problems (13 errors, 0 warnings)

ทุกตัวในนั้นคือ bug class จริงจาก catalogue โค้ด AI ของคอร์สนี้เอง — any ที่ boundary, floating promise, callback แบบ async ที่ตำแหน่งต้องการ callback คืน void — จับได้เพราะถาม checker ตรง ๆ ไม่ใช่ pattern-match text ไฟล์แยกที่มี switch ไม่ครบ (non-exhaustive) บน union สามสมาชิกได้ diagnostic ของตัวเอง @typescript-eslint/switch-exhaustiveness-check: Switch is not exhaustive. Cases not matched: "error" | "pending"

ต้นทุนก็จริงเหมือนกัน: ไฟล์เดียวกัน lint ด้วย projectService เปิด (สร้าง program ที่ type-check เต็มรูปแบบ) ใช้เวลา 0.78s; ไฟล์เดียวกันเป๊ะ lint ด้วย tseslint.configs.recommended เฉยๆ (ไม่มี type information, rule แบบ syntax เท่านั้น) ใช้เวลา 0.28s — เกือบ 3 เท่าบนไฟล์เล็ก ๆ ไฟล์เดียว และช่องว่างนี้จะกว้างขึ้นตามขนาดโปรแกรม เพราะทุกครั้งที่ lint ตอนนี้ต้องสร้าง ts.Program จริง นี่คือ trade ที่ต้องตัดสินใจเอง ไม่ใช่เกิดโดยบังเอิญ: typed rule จับ bug ได้กว้างกว่าจริง ๆ แต่มีต้นทุนที่โผล่มาทุกครั้งที่รัน eslint, ทุกครั้งที่ save, ทุก CI job

ทุกอย่างที่คุณอ่านมาในคอร์สนี้เกี่ยวกับ type ที่ “resolve เป็น X” ถูกตรวจด้วยเครื่องมือแบบเดียวกับที่ section นี้อธิบาย: assertion ที่ compile ไม่ผ่าน — ไม่ใช่ รันไม่ผ่าน — เมื่อ type ผิด tsd, expect-type, และ expectTypeOf ของ vitest คือไอเดียเดียวกันสามแบบ; โปรเจกต์ที่ใช้ Vitest อยู่แล้วมีตัวที่สามติดมาในตัวเลย

import { expectTypeOf, test } from "vitest";
type Camel<S extends string> = S extends `${infer H}_${infer T}` ? `${H}${Capitalize<Camel<T>>}` : S;
test("Camel converts snake_case to camelCase at the type level", () => {
expectTypeOf<Camel<"user_id">>().toEqualTypeOf<"userId">();
});

test นั้นผ่าน — แต่นี่คือส่วนที่ต้องระวัง: เปลี่ยน type ที่คาดหวังให้ผิด แล้วรันใหม่ด้วย vitest run เฉย ๆ:

// @skip-verify demonstrates a *silently passing* runtime test -- see prose for the tsc check that actually catches it
test("a wrong assertion, run with only `vitest run`", () => {
expectTypeOf<Camel<"user_id">>().toEqualTypeOf<"user_id">(); // wrong: should be "userId"
});

รันด้วย vitest run แล้วมันรายงาน 1 passed — expectTypeOf ไม่มี runtime behavior เลย ดังนั้น test runner ที่รันแค่ JavaScript จะไม่มีทางสังเกตว่า assertion ผิด สิ่งที่จับได้จริงคือการรัน tsc --noEmit --strict เฉย ๆ บนไฟล์ test นั้น (ตรวจใน scratch แล้ว): error TS2344: Type '"user_id"' does not satisfy the constraint '"Expected: literal string: user_id, Actual: literal string: userId"' — error message ถูกสร้างขึ้น จาก ความไม่ตรงกันนั้นเอง ซึ่งเป็นเหตุผลที่ type-level test ต้องมี compile step จริงใน CI ไม่ใช่แค่ vitest run ถึงจะมีความหมาย

diff ที่ generate มาแล้ว “type-check ผ่าน” แค่ผ่านบันไดขั้นต่ำสุดเท่านั้น แต่ละขั้นด้านล่างจับ bug คนละ class ที่ SpotTheBug ของคอร์สนี้เคยโชว์ว่า compile ผ่านเฉย ๆ:

  1. tsc --noEmit --strict — จับ implicit any, การใช้ null/undefined ผิด และ type mismatch จริง ๆ พลาด: any ที่ใช้ ตั้งใจ, unsafe cast (as), non-null assertion (!), และอะไรก็ตามที่แค่ runtime behavior ผิดแต่ type ดูถูกต้อง
  2. typed rule ของ typescript-eslint — จับสิ่งที่ขั้น 1 พลาดตรง ๆ ในฝั่ง any/cast/promise: no-explicit-any, no-unsafe-*, no-floating-promises, consistent-type-imports พลาด: logic ถูกหรือเปล่า
  3. type-level test (expectTypeOf / @ts-expect-error) — จับ utility type หรือ generic ที่ resolve เป็นของผิดเงียบ ๆ ซึ่งขั้น 1–2 ยอมรับว่า “typed ถูก” เพราะ type สอดคล้องกันเองแค่ผิด พลาด: runtime behavior ทั้งหมด
  4. runtime test — จับ behavior จริงที่ผิด รวมถึงทุกอย่างที่ type predicate โกหก (x is string { return true }) หรือ as cast ซ่อนไว้จากทุกขั้นข้างบน พลาด: .d.ts ที่ publish ตรงกับ shape จริงตอน runtime หรือเปล่า
  5. @arethetypeswrong (สำหรับ package ที่ publish แล้ว) — จับช่องว่างระหว่างสิ่งที่ ship กับสิ่งที่ resolver ของผู้ใช้เห็นจริง ๆ รันจริงกับ package ที่ publish แล้วและมีปัญหา CJS/ESM แบบรู้กันอยู่แล้ว (npx @arethetypeswrong/cli --from-npm [email protected]):
⚠️ A require call resolved to an ESM JavaScript file, which is an error in Node and some bundlers.
┌───────────────────┬──────────────────────────────┐
│ │ "chalk" │
├───────────────────┼──────────────────────────────┤
│ node10 │ 🟢 │
├───────────────────┼──────────────────────────────┤
│ node16 (from CJS) │ ⚠️ ESM (dynamic import only) │
├───────────────────┼──────────────────────────────┤
│ node16 (from ESM) │ 🟢 (ESM) │
├───────────────────┼──────────────────────────────┤
│ bundler │ 🟢 │
└───────────────────┴──────────────────────────────┘

ไม่มีคำสั่งเดียวที่รันทั้งบันได — ที่ใกล้เคียงที่สุดคือการต่อขั้นที่เกี่ยวกับ CI: tsc --noEmit --strict && eslint . && vitest run ครอบคลุมขั้น 1, 2 และ (ถ้า suite มี test expectTypeOf/@ts-expect-error) 3 กับ 4 ในบรรทัดเดียว; ขั้น 5 เป็น check แยกที่ทำเป็นครั้งคราว ไม่ใช่ทุก commit

flowchart TB
  a["tsc --noEmit --strict"] -->|misses: any, as, !, wrong logic| b["typed eslint rules"]
  b -->|misses: is the type itself right?| c["expectTypeOf / @ts-expect-error"]
  c -->|misses: runtime behavior| d["runtime tests"]
  d -->|misses: does a consumer's resolver see this?| e["@arethetypeswrong (publish time)"]
บันไดตรวจสอบ และสิ่งที่แต่ละขั้นจับได้จริง

tsc คือโปรแกรมเดียวที่ทำสองงาน และทุกเครื่องมือในโมดูลนี้มีไว้เพื่อตอบคำถามเดียวกันเกี่ยวกับ code ชิ้นหนึ่ง: มันถูก check อยู่หรือเปล่า และ check ด้วยอะไร? bundler ไม่ตอบทั้งสองอย่าง — มันลบ type ทิ้งโดยไม่อ่านเลย tsc --noEmit --strict ตอบว่า “shape ถูกไหม” แต่หยุดทันทีที่ type บอกว่า any หรือ cast บอกว่า as typed eslint rule ขยายคำถามเดียวกันไปยัง pattern ที่ checker เองไม่ flag (floating promise ก็ยัง type-check เป็น Promise<void> ได้ปกติ ไม่มีอะไรในนั้นเป็น type error) type-level test ตอบคำถามที่ต่างไปเลย — ไม่ใช่ “code นี้ type-check ผ่านไหม” แต่ “type ตัวนี้เท่ากับ type ที่ฉันอ้างจริงไหม” ซึ่งเป็นเครื่องมือเดียวในลิสต์นี้ที่จับ utility type ที่คืนของผิดเงียบ ๆ ได้ ทั้งที่ typed ถูกสมบูรณ์แบบ ไม่มีเครื่องมือไหนแทนที่อีกตัวได้ — แต่ละตัวกำลัง check สิ่งที่ตัวอื่นมองไม่เห็นโดยโครงสร้าง

เครื่องมือจับอะไรพลาดอะไรต้นทุน
tsc --noEmit --strictimplicit any, การใช้ null/undefined ผิด, shape mismatchany ที่ตั้งใจ, as, !, logic ที่ผิดตอน runtimeหลักวินาที ใน CI หรือตอน save
typed typescript-eslintfloating promise, any ที่ leak ไม่ปลอดภัย, switch ไม่ครบ, import style ผิดlogic เองถูกหรือเปล่า~3 เท่าของ lint แบบ syntax-only จาก timing ของคอร์สนี้เอง และต้นทุน setup จริง (projectService, tsconfig ที่ใช้งานได้)
expectTypeOf / @ts-expect-errortype ที่ resolve เป็นของผิด แม้จะสอดคล้องกันเองทุกอย่างเกี่ยวกับ runtime behaviorต้องมี tsc --noEmit แยกของตัวเอง — vitest run เฉย ๆ ไม่ check ให้
runtime testbehavior จริง รวมถึงสิ่งที่ as/!/type-predicate ที่โกหกซ่อนไว้.d.ts ที่ publish ตรงกับสิ่งที่ bundler ของผู้ใช้ resolve จริงไหมต้นทุน test suite ปกติ
@arethetypeswrongช่องว่างของ exports-map/.d.mts/dual-package ระหว่างสิ่งที่ ship กับสิ่งที่ resolve จริงทุกอย่างก่อนหน้าการ publishรันเป็นครั้งคราว ไม่ใช่ทุก commit
  • “type-check ผ่าน” แปลว่า code ถูกต้อง มันแปลว่า shape สอดคล้องกันเองเท่านั้น any, as, !, และ type predicate ที่โกหก (function isString(x: unknown): x is string { return true; }) ทั้งหมด compile ผ่านเรียบร้อยพร้อม assert สิ่งที่ checker ไม่เคยตรวจสอบเลย
  • typed ESLint rule กับ tsc --noEmit check สิ่งเดียวกัน เลยมีแค่ตัวเดียวก็พอ มันซ้อนกันแทบไม่มีเลย: tsc flag shape mismatch; typed rule flag pattern ที่ checker จับไม่ได้โดยโครงสร้าง (floating promise, any ที่ leak ผ่านสามชั้น) เพราะ pattern พวกนั้นยังให้ type ที่ valid สมบูรณ์
  • expectTypeOf test ที่ผ่าน พิสูจน์ว่า type ถูกตอน runtime ด้วย มันพิสูจน์แค่ว่า type ถูก ในฐานะ type — expectTypeOf ไม่มี runtime behavior เลย ดังนั้น vitest run เฉย ๆ จะรายงาน “passed” อย่างสบายใจบน test ที่ type-level assertion ผิดจริง (พิสูจน์แล้วข้างบน); มีแค่ tsc --noEmit จริงบนไฟล์นั้นเท่านั้นที่จับได้
  • ถ้า package ship .d.ts มา type ของมันเชื่อถือได้กับผู้ใช้ทุกคน ผลรันจริงของ @arethetypeswrong ข้างบนแสดงว่า package ที่ใช้กันแพร่หลายและ typed ดี (chalk) ยังมีช่องว่างการ resolve สำหรับ consumer shape หนึ่ง (CJS require) — การ ship declaration เป็นเงื่อนไขจำเป็น ไม่ใช่เงื่อนไขพอ

ก่อนเชื่อ diff ที่ generate มาเพราะ “มัน compile ผ่าน” ให้ตรวจว่ามันผ่านบันไดขั้นไหนจริง ๆ:

  • diff เพิ่ม any, as, หรือ ! เข้ามาไหม? ไม่มีตัวไหนถูกจับโดย tsc --noEmit เฉย ๆ — นั่นคือจุดบอดที่รู้อยู่แล้วของขั้น 1 ไม่ใช่ bug ของ checker
  • มีฟังก์ชัน async ใหม่ที่ผลลัพธ์ไม่เคยถูก await, เก็บ, หรือทิ้งด้วย void อย่างชัดเจนไหม? tsc เห็น Promise<void> ที่ valid; มีแค่ typed lint rule (no-floating-promises) เท่านั้นที่เห็น bug
  • utility type ใหม่หรือที่แก้ไข ship มาโดยไม่มี test expectTypeOf/@ts-expect-error ไหม? utility type อาจ typed ถูกและ resolve เป็นของผิดพร้อมกันได้ — ทางเดียวที่จะรู้คือ assert มัน ไม่ใช่มองด้วยตา
  • eslint.config.mjs ขาด parserOptions.projectService (หรือ project list ที่เทียบเท่า) ไหม? ถ้าไม่มี typed rule ของ typescript-eslint จะตกกลับไปเป็น syntax-only check เงียบ ๆ — ไม่มี error แค่ lint อ่อนลงเงียบ ๆ

🐛 หา bug ในโค้ดนี้

function loadConfig(raw: string) {
return JSON.parse(raw);
}
async function readConfigFile(path: string) {
const raw = await import(path, { with: { type: "json" } });
return loadConfig(JSON.stringify(raw.default));
}
export function start() {
readConfigFile("./config.json");
console.log("started");
}
เฉลย

ไฟล์นี้ compile ผ่านไม่มี error เลย ภายใต้ tsc --noEmit --strict — จุดบอดทุกจุดของมันมองไม่เห็นโดย checker เพียวๆ return type ของ loadConfig คือ any (จาก JSON.parse) เลยปิดการ check ให้ทุกอย่างที่ตามมาเงียบ ๆ; readConfigFile ประกาศเป็น async แต่ผลลัพธ์ไม่เคยถูก await, เก็บ, หรือ void-mark ใน start() เป็น floating promise ที่มีแค่ typed lint rule (no-floating-promises) เท่านั้นที่จับได้; และถ้า readConfigFile reject เมื่อไหร่ (ไฟล์หาย, JSON ผิด) ไม่มีอะไรสังเกตเห็นเลย — process ทำงานต่อผ่าน console.log("started") เหมือนไม่มีอะไรเกิดขึ้น รันบันไดแล้วจะเจอทุกจุด: tsc --noEmit --strict รายงาน 0 problem, eslint ที่มี no-floating-promises กับ no-explicit-any รายงาน 2 จุด และมีแค่ runtime test ที่ลบ config.json จริง ๆ ก่อนเท่านั้นที่จะโชว์ unhandled rejection

TypeScript compiler ทำงานสองอย่างอะไร?
ทำไม bundler อย่าง esbuild ถึง emit JavaScript ที่ type-check ไม่ผ่านออกมาได้?
วิธีที่แนะนำในการจับ type error จริง ๆ เมื่อ bundler เป็นคน emit คืออะไร?
ภายใต้ strict, tsc --noEmit รายงานอะไรตรงนี้ และทำไม bundler ถึงเงียบ?
function double(items) {
  return items.map((n) => n * 2);
}
console.log(double([1, 2, 3]));
ไฟล์ test ที่ generate มามี expectTypeOf<Camel<"user_id">>().toEqualTypeOf<"user_id">() (type จริงคือ "userId") รัน vitest run แล้วรายงานว่า passed ทำไม?
ทำไม @arethetypeswrong/cli --from-npm [email protected] ถึงรายงาน warning จริง ทั้งที่ chalk ship .d.ts มาและใช้กันแพร่หลาย?