The Type-System Mindset
type คือเซตของ value
หัวข้อที่มีชื่อว่า “type คือเซตของ value”นี่คือการปรับมุมมองที่ปลดล็อก TypeScript เลิกคิดว่า type เป็น ป้ายชื่อ แล้วเริ่มคิดว่าเป็น เซต — คือกลุ่มของ value ทุกตัวที่เป็นสมาชิกของตัวเองอย่างถูกต้อง
booleanคือเซต{ true, false }— มีสมาชิกสองตัวพอดีundefinedคือเซตที่มีสมาชิก หนึ่ง ตัว:{ undefined }stringคือเซต (อนันต์) ของ string ทั้งหมด42— ใช่ literal — คือเซตที่มีสมาชิก หนึ่ง ตัว:{ 42 }นี่คือ literal type
พอ type กลายเป็นเซต operation ที่คุณรู้จากคณิตศาสตร์อยู่แล้วก็อธิบายทั้ง system ได้
Assignability คือความสัมพันธ์แบบ subset
หัวข้อที่มีชื่อว่า “Assignability คือความสัมพันธ์แบบ subset”a assign ให้ b ได้ ก็ต่อเมื่อ เซต a เป็น subset ของเซต b นั่นคือกฎทั้งหมด
// @expect-error TS2322let 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 เลย — แค่ถามว่า “เซตนี้อยู่ในเซตนั้นไหม”
union คือ set union; intersection คือ set intersection
หัวข้อที่มีชื่อว่า “union คือ set union; intersection คือ set intersection”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 string | numberคือ union ของสองเซต — value ที่อยู่ใน เซตใดเซตหนึ่ง union จะ กว้างกว่า (สมาชิกเยอะกว่า)A & Bคือ intersection — value ที่อยู่ใน ทั้งสอง สำหรับ object type แปลว่า “มี property ของทั้งสองครบ” นั่นคือเหตุผลที่ intersection ทำให้ แคบลง
เรื่องน่าประหลาดใจที่พบบ่อยหลุดออกมาจากตรงนี้: string & number คือเซตว่าง ไม่มี value ไหนเป็นทั้ง string และ number — type ของตัวเองจึงเป็น never
สองปลายสุด: never และ unknown
หัวข้อที่มีชื่อว่า “สองปลายสุด: never และ unknown”เซตมีตัวเล็กสุดและใหญ่สุด และ TypeScript ตั้งชื่อให้:
neverคือ เซตว่าง{ }— ไม่มี value เลย ไม่มีอะไร assign ให้neverได้ (คุณสร้างสมาชิกของเซตว่างไม่ได้) แต่neverassign ให้ ทุกอย่าง ได้ (เซตว่างเป็น subset ของทุกเซต)neverคือ type ของ function ที่ไม่มีทาง return และเป็นสัญญาณว่า branch นั้นไปไม่ถึงunknownคือ เซตของ value ทั้งหมด — top type ทุกอย่าง assign ให้unknownได้ แต่unknownassign ให้แทบไม่มีอะไรได้จนกว่าคุณจะ narrow ก่อน พูดง่าย ๆunknownคือanyเวอร์ชันที่ปลอดภัยเรื่อง type
// @expect-error TS2322let n: never;let u: unknown = "anything goes";
let s: string = u; // ❌ unknown is too wide — narrow it firstlet x: unknown = 42; // ✅ everything fits into unknownany ทำลาย model นี้โดยตั้งใจ: เป็นการขอ ออก จากการเช็คเซตทั้งหมด นั่นคือเหตุผลที่อันตราย ส่วน unknown รักษา model ไว้และบังคับให้คุณ narrow
พิสูจน์: any, unknown, never กับความผิดพลาดเดียวกันเป๊ะ
หัวข้อที่มีชื่อว่า “พิสูจน์: any, unknown, never กับความผิดพลาดเดียวกันเป๊ะ”การพูดลอย ๆ ไม่มีค่าอะไร — เอาทั้งสามมาเจอ bug เดียวกันแล้วดูว่า model ทำนายสิ่งที่เกิดกับแต่ละตัวได้แม่นแค่ไหน
// @runconst withAny: any = "42";withAny.toFixed(2); // compiles — any opts out of checking, crashes at runtimeTypeError: withAny.toFixed is not a functionany ไม่ได้เป็นสมาชิกของ model เซตเลย — มันคือรูที่เจาะทะลุ model ไว้ compiler ปล่อยให้ .toFixed ผ่านไปโดยไม่เช็คอะไรเลย แล้วความผิดพลาด (string ไม่มี toFixed) ก็รอดไปจนถึง runtime crash
Same mistake, three types: any crashes, unknown and never don't compile
// @expect-error TS18046 TS2339const 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 กด 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 TS2322type 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 แบบเต็ม ๆ
Mental Model
หัวข้อที่มีชื่อว่า “Mental Model”ทุก 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
ข้อแลกเปลี่ยน
หัวข้อที่มีชื่อว่า “ข้อแลกเปลี่ยน”| 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แต่พังภายใต้unknownneverแปลว่า “เกิด error ขึ้น”neverแปลว่า “เซตว่าง” — ไม่มี value ไหนมี type นี้ได้เลย function ที่ return type เป็นneverไม่ได้พัง มันคือ function ที่พิสูจน์ได้ว่าไม่มีทาง return เลย (throw หรือ loop เสมอ)switchแบบ exhaustive ต้องมี comment บอกว่า “ไม่ควรเกิดขึ้น” มันต้องมีตัวแปร typeneverใน branchdefaultแทน — comment หยุด compile ไม่ได้เมื่อมี union member ใหม่ถูกเพิ่มแล้วไม่ถูกจัดการ แต่การ assign ให้neverหยุดได้จริง
อ่านโค้ดที่ AI เขียน
หัวข้อที่มีชื่อว่า “อ่านโค้ดที่ AI เขียน”Code ที่ generate มาหยิบ any บ่อยกว่า unknown มาก เพราะ any ไม่ต้อง narrow ต่อ — นั่นคือเหตุผลที่มันซ่อน bug กลุ่มที่บทนี้เปิดเรื่องไว้พอดี ตอน review type annotation บน data ที่ไม่น่าเชื่อถือหรือมาจากภายนอกให้เช็ค:
- เป็น
anyตรงที่ควรเป็นunknownไหม? ถ้าไม่มีอะไรข้างล่าง narrow value ก่อนใช้anyจะปล่อยให้ความผิดพลาดจริง compile ผ่านสะอาด เหมือนwithAny.toFixed(2)ข้างบนเป๊ะ switchแบบ exhaustive มีdefaulttypeneverจริงไหม หรือมีแค่ 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 จริงอย่างชัดเจน