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

Narrowing

union มีประโยชน์ก็เพราะคุณ narrow ได้ TypeScript อ่าน control flow ของคุณ — if, switch, typeof, return — แล้วย่อ type ในแต่ละ branch ลงเหลือ case ที่ต้องเป็นจริงตรงนั้น นี่คือ control-flow analysis หนึ่งในส่วนที่ฉลาดที่สุดของ compiler

function printId(id: string | number) {
if (typeof id === "string") {
id.toUpperCase(); // ✅ ตรงนี้ id เป็น string
} else {
id.toFixed(2); // ✅ ตรงนี้ id เป็น number
}
}

ใน if type เป็น string; branch else ตัด string ออกจาก union เหลือ number คุณไม่ได้ cast อะไรเลย — compiler narrow จาก runtime check จริงที่เข้าใจ

TypeScript เข้าใจ JavaScript check ทั่วไปหลายตัวและ narrow ตามผลของ check เหล่านั้น:

  • typeof x — narrow primitive: "string", "number", "boolean", "object", "function", "undefined", "symbol", "bigint"
  • instanceof — narrow เป็น class จาก prototype chain
  • "key" in obj — narrow object union ด้วยการมี property
  • truthinessif (x) ตัด null, undefined, 0, "", false ออกจากความเป็นไปได้
  • equalityif (x === "a") narrow เป็น literal นั้น; เทียบสองตัวแปร narrow ทั้งคู่
function area(shape: Circle | Square) {
if ("radius" in shape) {
return Math.PI * shape.radius ** 2; // shape เป็น Circle
}
return shape.side ** 2; // shape เป็น Square
}

วิธีที่สะอาดที่สุดในการ model “หนึ่งใน object shape หลายแบบ” คือ discriminated union: ทุก member มี literal field ร่วมกัน (ตัว discriminant หรือ tag) แล้ว switch บน field นั้นเพื่อ narrow เหลือ member ที่ถูก

type Shape =
| { kind: "circle"; radius: number }
| { kind: "square"; side: number }
| { kind: "rect"; width: number; height: number };
function area(s: Shape): number {
switch (s.kind) {
case "circle": return Math.PI * s.radius ** 2; // s เป็น member circle
case "square": return s.side ** 2;
case "rect": return s.width * s.height;
}
}
flowchart TB
  u["Shape (union)
circle | square | rect"] -->|switch on kind| c["kind === circle
→ radius available"]
  u --> sq["kind === square
→ side available"]
  u --> r["kind === rect
→ width, height available"]
discriminant field narrow union เหลือ member เดียว

pattern นี้อยู่ทุกที่: Redux action, API response, state machine, result type เวลาคุณเผลอจะหยิบ class hierarchy มา model variant discriminated union มักเรียบง่ายและปลอดภัยกว่า

นี่คือของรางวัล เพิ่ม branch default ที่ assign value เข้า never แล้ว compiler จะ error ในวันที่มีคนเพิ่ม union member ใหม่แล้วลืม handle:

function area(s: Shape): number {
switch (s.kind) {
case "circle": return Math.PI * s.radius ** 2;
case "square": return s.side ** 2;
case "rect": return s.width * s.height;
default:
const _exhaustive: never = s; // ❌ error ถ้ามี kind ใหม่ที่ยังไม่ handle
return _exhaustive;
}
}

ใน default ทุก case ที่รู้จักถูก narrow ออกไปหมด s จึงเป็น never เพิ่ม member "triangle" แล้ว s จะไม่ใช่ never ตรงนั้นอีก — การ assign compile ไม่ผ่าน ชี้ตรงไป case ที่ขาด เปลี่ยน “ฉัน handle ครบทุก variant หรือยัง?” จากคำถามใน code review เป็น compile error

เมื่อ narrowing ต้องใช้ logic เอง เขียน type-guard function ที่ return type เป็น type predicate x is T:

function isString(x: unknown): x is string {
return typeof x === "string";
}
function handle(v: unknown) {
if (isString(v)) {
v.toUpperCase(); // ✅ v ถูก narrow เป็น string ด้วย guard
}
}

predicate คือคำสัญญาต่อ compiler: “ถ้าอันนี้ return true ให้มอง argument เป็น T” นี่คือวิธี narrow unknown จากข้อมูลภายนอก และวิธีที่ library เปิด validation ที่ type system เชื่อถือ

ญาติของ type guard คือ assertion function ซึ่ง throw แทนที่จะ return boolean และ narrow ให้ตลอด scope ที่เหลือ:

function assert(cond: unknown, msg: string): asserts cond {
if (!cond) throw new Error(msg);
}
function use(v: string | null) {
assert(v !== null, "v must be set");
v.toUpperCase(); // ✅ v เป็น string ตั้งแต่ตรงนี้ไป — assert ตัด null ออก
}

หลังเรียก compiler รู้ว่าเงื่อนไขที่ assert เป็นจริง (ไม่งั้นคง throw ไปแล้ว) เลย narrow ตาม นี่คือวิธีที่ helper แบบ assert และ assert ของ Node ให้ type narrowing มาฟรี ๆ

control-flow narrowing คืออะไร?
อะไรนิยาม discriminated union?
การ assign เข้า `never` ใน branch default ช่วยอะไร?
function ที่ return `x is string` (type predicate) ทำอะไร?
assertion function `asserts cond` ทำอะไรหลังถูกเรียก?