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

The Type-System Mindset

นี่คือการปรับมุมมองที่ปลดล็อก TypeScript เลิกคิดว่า type เป็น ป้ายชื่อ แล้วเริ่มคิดว่าเป็น เซต — คือกลุ่มของ value ทุกตัวที่เป็นสมาชิกของตัวเองอย่างถูกต้อง

  • boolean คือเซต { true, false } — มีสมาชิกสองตัวพอดี
  • undefined คือเซตที่มีสมาชิก หนึ่ง ตัว: { undefined }
  • string คือเซต (อนันต์) ของ string ทั้งหมด
  • 42 — ใช่ literal — คือเซตที่มีสมาชิก หนึ่ง ตัว: { 42 } นี่คือ literal type

พอ type กลายเป็นเซต operation ที่คุณรู้จากคณิตศาสตร์อยู่แล้วก็อธิบายทั้ง system ได้

a assign ให้ b ได้ ก็ต่อเมื่อ เซต a เป็น subset ของเซต b นั่นคือกฎทั้งหมด

// @expect-error TS2322
let x: 42 = 42;
let y: number = x; // ✅ {42} ⊆ all numbers
let a: number = 42;
let b: 42 = a; // ❌ all numbers ⊄ {42}
// Error: Type 'number' is not assignable to type '42'.

literal 42 พอดีเข้าไปใน number ได้เพราะ {42} เป็น subset ของทุกจำนวน ทางกลับกันพังเพราะจำนวนส่วนใหญ่ไม่ใช่ 42 คุณไม่ต้องท่องกฎ assignability เลย — แค่ถามว่า “เซตนี้อยู่ในเซตนั้นไหม”

flowchart TB
  subgraph u["A | B  (union = OR)"]
    ua["string"] --- ub["number"]
    note1["value ที่อยู่ในเซตใดเซตหนึ่ง
ยิ่งมี value เยอะ ยิ่งกว้าง"]
  end
  subgraph i["A และ B  (intersection = AND)"]
    note2["value ที่อยู่ในทั้งสองเซต
ยิ่งมี value น้อย ยิ่งแคบ"]
  end
type ในฐานะเซต: union ทำให้กว้างขึ้น intersection ทำให้แคบลง
  • string | number คือ union ของสองเซต — value ที่อยู่ใน เซตใดเซตหนึ่ง union จะ กว้างกว่า (สมาชิกเยอะกว่า)
  • A & B คือ intersection — value ที่อยู่ใน ทั้งสอง สำหรับ object type แปลว่า “มี property ของทั้งสองครบ” นั่นคือเหตุผลที่ intersection ทำให้ แคบลง

เรื่องน่าประหลาดใจที่พบบ่อยหลุดออกมาจากตรงนี้: string & number คือเซตว่าง ไม่มี value ไหนเป็นทั้ง string และ number — type ของตัวเองจึงเป็น never

เซตมีตัวเล็กสุดและใหญ่สุด และ TypeScript ตั้งชื่อให้:

  • never คือ เซตว่าง { } — ไม่มี value เลย ไม่มีอะไร assign ให้ never ได้ (คุณสร้างสมาชิกของเซตว่างไม่ได้) แต่ never assign ให้ ทุกอย่าง ได้ (เซตว่างเป็น subset ของทุกเซต) never คือ type ของ function ที่ไม่มีทาง return และเป็นสัญญาณว่า branch นั้นไปไม่ถึง
  • unknown คือ เซตของ value ทั้งหมด — top type ทุกอย่าง assign ให้ unknown ได้ แต่ unknown assign ให้แทบไม่มีอะไรได้จนกว่าคุณจะ narrow ก่อน พูดง่าย ๆ unknown คือ any เวอร์ชันที่ปลอดภัยเรื่อง type
// @expect-error TS2322
let n: never;
let u: unknown = "anything goes";
let s: string = u; // ❌ unknown is too wide — narrow it first
let x: unknown = 42; // ✅ everything fits into unknown

any ทำลาย model นี้โดยตั้งใจ: เป็นการขอ ออก จากการเช็คเซตทั้งหมด นั่นคือเหตุผลที่อันตราย ส่วน unknown รักษา model ไว้และบังคับให้คุณ narrow

การพูดลอย ๆ ไม่มีค่าอะไร — เอาทั้งสามมาเจอ bug เดียวกันแล้วดูว่า model ทำนายสิ่งที่เกิดกับแต่ละตัวได้แม่นแค่ไหน

// @run
const withAny: any = "42";
withAny.toFixed(2); // compiles — any opts out of checking, crashes at runtime
TypeError: withAny.toFixed is not a function

any ไม่ได้เป็นสมาชิกของ model เซตเลย — มันคือรูที่เจาะทะลุ model ไว้ compiler ปล่อยให้ .toFixed ผ่านไปโดยไม่เช็คอะไรเลย แล้วความผิดพลาด (string ไม่มี toFixed) ก็รอดไปจนถึง runtime crash

Same mistake, three types: any crashes, unknown and never don't compile

// @expect-error TS18046 TS2339
const withAny: any = "42";
withAny.toFixed(2); // compiles — any opts out of checking, crashes at runtime
const withUnknown: unknown = "42";
withUnknown.toFixed(2); // TS18046 — unknown forces you to narrow first
declare const withNever: never;
withNever.toFixed(2); // TS2339 — never has no members at all
Open in TS Playground

กด Check types: บรรทัดของ any ไม่มีอะไรเลย เพราะ any assign ให้และรับจากทุกอย่างได้โดยไม่ถามอะไร บรรทัดของ unknown พังด้วย TS18046 — unknown คือเซตของ value ทั้งหมด และ .toFixed ไม่ใช่ property ที่ทุก value มี compiler จึงปฏิเสธจนกว่าคุณจะ narrow บรรทัดของ never พังด้วย TS2339 ด้วยเหตุผลคนละแบบ: never คือเซตว่าง จึงไม่มี property เลยสักตัว — ไม่มีอะไรให้เรียก .toFixed ได้เลย ความผิดพลาดรูปแบบเดียวกัน แต่ผลลัพธ์ต่างกันสามแบบสิ้นเชิง และ model เซตทำนายทุกอันได้ถูกต้อง: any ไม่ใช่เซต (มันคือทางหนี), unknown คือเซตใหญ่สุด (ยังไม่ narrow), never คือเซตเล็กสุด (ไม่มีอะไรให้ใช้เลย)

never ในทางปฏิบัติ: code ที่ไปไม่ถึงและ case ที่เป็นไปไม่ได้

หัวข้อที่มีชื่อว่า “never ในทางปฏิบัติ: code ที่ไปไม่ถึงและ case ที่เป็นไปไม่ได้”

never ไม่ใช่แค่เซตว่างเชิงนามธรรม — มันคือ type จริงที่ infer ให้ function ที่ return แบบปกติไม่ได้:

function fail(msg: string): never {
throw new Error(msg);
}
const result: never = fail("boom"); // ✅ — fail never returns, so this line never runs

โค้ดนี้ compile ผ่านเพราะ fail ไม่ผลิต value เลยจริง ๆ — ทุกทางเดินจบด้วย throw compiler เชื่อ function ที่ return never เต็มที่: code ใด ๆ หลังเรียก function แบบนี้ถือว่าไปไม่ถึง

ไอเดียเดียวกันปรากฏเป็นตาข่ายนิรภัยใน switch เมื่อทุก case ของ union ถูกจัดการหมดแล้ว สิ่งที่เหลืออยู่ถูก narrow เป็น never — assign ให้ never ได้เพราะไม่มีอะไรเหลืออยู่จริง ๆ ที่มันจะเป็นได้:

type Shape = { kind: "circle"; radius: number } | { kind: "square"; side: number };
function area(shape: Shape): number {
switch (shape.kind) {
case "circle":
return Math.PI * shape.radius ** 2;
case "square":
return shape.side ** 2;
default:
const exhaustive: never = shape; // ✅ every case handled — nothing reaches here
throw new Error(`Unhandled shape: ${exhaustive}`);
}
}

เพิ่มสมาชิกตัวที่สามให้ Shape ทีหลังแล้วลืมจัดการ บรรทัดนี้เป๊ะ ๆ จะเลิก compile ผ่าน — shape ใน branch default ไม่ถูก narrow ไปจนถึง never แล้ว ดังนั้นการ assign มันให้ตัวแปรชนิด never จึงพัง:

// @expect-error TS2322
type Shape =
| { kind: "circle"; radius: number }
| { kind: "square"; side: number }
| { kind: "triangle"; base: number; height: number };
function area(shape: Shape): number {
switch (shape.kind) {
case "circle":
return Math.PI * shape.radius ** 2;
case "square":
return shape.side ** 2;
default:
const exhaustive: never = shape; // ❌ 'triangle' case wasn't handled
throw new Error(`Unhandled shape: ${exhaustive}`);
}
}

นี่คือไอเดีย never-คือ-เซตว่าง ที่ทำงานจริง: ทันทีที่ shape ยังเป็น { kind: "triangle"; ... } ได้จริง เซตของ value ที่เป็นไปได้ของมันก็ไม่ว่างอีกต่อไป มันจึงไม่พอดีกับ never แล้ว compiler จึงบอกคุณว่ามี case ขาดหายไป — ตอน compile time ไม่ใช่แบบตกหล่นเงียบ ๆ บท narrowing ครอบคลุมเรื่อง exhaustiveness check แบบเต็ม ๆ

ทุก type ในบทนี้เป็นจุดบนสเกลเดียวกัน: never คือเซตว่างที่ล่างสุด unknown คือเซตของทุกอย่างที่บนสุด และ type อื่น ๆ ทุกตัวอยู่ตรงกลางที่ไหนสักแห่ง ขนาดขึ้นอยู่กับว่ามันอนุญาต value กี่ตัว any ไม่อยู่บนสเกลนั้นเลย — มันคือรูที่เจาะเข้า model โดยตั้งใจ นี่คือเหตุผลที่ความผิดพลาด .toFixed เดียวกันมีพฤติกรรมต่างกันมากขึ้นอยู่กับว่าคุณใช้ตัวไหนในสามตัว

flowchart LR
  never["never
(empty set — bottom)"] --> mid["string, number, User, ...
(sized sets in between)"]
  mid --> unknown["unknown
(all values — top)"]
  any["any
(NOT a set — opts out entirely)"] -.->|breaks the model on purpose| mid
never, unknown และ any เทียบกับ model เซต
Typeข้อดีต้นทุน
anyไม่มีแรงเสียดทาน — ทำ operation อะไรก็ได้ขอออกจาก model เซตทั้งหมด ความผิดพลาดที่บทนี้แสดงให้ดู compile ผ่านสะอาดแล้วโผล่มาแค่ตอน runtime
unknownรับได้ทุกอย่างเหมือน any แต่ยังคงการเช็คไว้ครบใช้ทำอะไรไม่ได้เลยจนกว่าจะ narrow — งานล่วงหน้าที่ boundary เยอะกว่า
neverทำให้ “branch นี้เป็นไปไม่ได้” เป็นข้อเท็จจริงตอน compile time ไม่ใช่แค่ commentมีประโยชน์ก็ต่อเมื่อ case อื่นทั้งหมดถูกจัดการจริง ๆ แล้ว เพิ่ม case ทีหลังโดยไม่อัปเดต default จะได้ compile error จริง (ที่ถูกต้อง)
switch แบบ exhaustive พร้อมเช็ค neverจับ union member ที่ไม่ถูกจัดการได้ทันทีที่ถูกเพิ่มเข้ามาต้องมี boilerplate default: const x: never = value ในทุก switch ที่ต้องการการรับประกันนี้
  • any กับ unknown เป็น type “ยอมแพ้” แบบเดียวกัน ในทางปฏิบัติเป็นขั้วตรงข้ามกัน: unknown รักษา model เซตไว้ครบและบล็อกทุก operation จนกว่าจะ narrow; any ปิด model ทิ้ง นั่นคือเหตุผลที่ code เดียวกัน (x.toFixed(2)) compile ผ่านภายใต้ any แต่พังภายใต้ unknown
  • never แปลว่า “เกิด error ขึ้น” never แปลว่า “เซตว่าง” — ไม่มี value ไหนมี type นี้ได้เลย function ที่ return type เป็น never ไม่ได้พัง มันคือ function ที่พิสูจน์ได้ว่าไม่มีทาง return เลย (throw หรือ loop เสมอ)
  • switch แบบ exhaustive ต้องมี comment บอกว่า “ไม่ควรเกิดขึ้น” มันต้องมีตัวแปร type never ใน branch default แทน — comment หยุด compile ไม่ได้เมื่อมี union member ใหม่ถูกเพิ่มแล้วไม่ถูกจัดการ แต่การ assign ให้ never หยุดได้จริง

Code ที่ generate มาหยิบ any บ่อยกว่า unknown มาก เพราะ any ไม่ต้อง narrow ต่อ — นั่นคือเหตุผลที่มันซ่อน bug กลุ่มที่บทนี้เปิดเรื่องไว้พอดี ตอน review type annotation บน data ที่ไม่น่าเชื่อถือหรือมาจากภายนอกให้เช็ค:

  • เป็น any ตรงที่ควรเป็น unknown ไหม? ถ้าไม่มีอะไรข้างล่าง narrow value ก่อนใช้ any จะปล่อยให้ความผิดพลาดจริง compile ผ่านสะอาด เหมือน withAny.toFixed(2) ข้างบนเป๊ะ
  • switch แบบ exhaustive มี default type never จริงไหม หรือมีแค่ comment อ้างว่าครอบคลุมทุก case?
  • ถ้า function ถูก annotate : never มันไม่ return จริง ๆ ไหม (throw/loop ทุกทาง) หรือ never ถูกใช้แทน “ยังไม่รู้ type นี้”?

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

function getSetting(key: string): any {
const settings: Record<string, string> = { theme: "dark" };
return settings[key];
}
const timeout = getSetting("timeout");
console.log(timeout.toFixed(0));
เฉลย

โค้ดนี้ compile ผ่านโดยไม่มี error เลยแล้วพังตอน runtime getSetting ถูก annotate ให้ return any ดังนั้นทุกการใช้ผลลัพธ์ของมัน — รวมถึง .toFixed(0) ซึ่งเป็น method เฉพาะของ number — ไม่ถูกเช็คเลย settings["timeout"] จริง ๆ เป็น undefined (key นี้ไม่มีใน settings) ดังนั้น timeout.toFixed(0) จึง throw TypeError: Cannot read properties of undefined (reading 'toFixed') ถ้า getSetting return unknown แทน จุดเรียกนี้เป๊ะ ๆ จะ compile ไม่ผ่านด้วย TS18046 — บังคับให้มี narrowing check ก่อนที่ crash จะไปถึง runtime ได้เลย any บน function ที่พฤติกรรมจริงคือ “ค้นหาแล้วอาจไม่เจอ” เป็นตัวเลือกที่แย่กว่า unknown บวก check จริงอย่างชัดเจน

ใน model "type คือเซต" เมื่อไรที่ type A assign ให้ type B ได้?
type ของ string & number คืออะไร และเพราะอะไร?
never แทนเซตอะไร?
unknown ต่างจาก any อย่างไร?
tsc รายงานอะไรสำหรับสองบรรทัดนี้?
const withAny: any = "42";
withAny.toFixed(2);

const withUnknown: unknown = "42";
withUnknown.toFixed(2);
โค้ดนี้ compile ผ่านไหม เพราะอะไร?
type Shape =
  | { kind: "circle"; radius: number }
  | { kind: "square"; side: number }
  | { kind: "triangle"; base: number; height: number };

function area(shape: Shape): number {
  switch (shape.kind) {
    case "circle": return Math.PI * shape.radius ** 2;
    case "square": return shape.side ** 2;
    default:
      const exhaustive: never = shape;
      throw new Error(`Unhandled: ${exhaustive}`);
  }
}
function ประกาศเป็น function fail(msg: string): never { throw new Error(msg); } ทำไมถึง compile ผ่าน?