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

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(); // ✅ here id is string
} else {
id.toFixed(2); // ✅ here id is 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
  • truthiness — if (x) ตัด null, undefined, 0, "", false ออกจากความเป็นไปได้
  • equality — if (x === "a") narrow เป็น literal นั้น; เทียบสองตัวแปร narrow ทั้งคู่
interface Circle { kind: "circle"; radius: number }
interface Square { kind: "square"; side: number }
function area(shape: Circle | Square) {
if ("radius" in shape) {
return Math.PI * shape.radius ** 2; // shape is Circle
}
return shape.side ** 2; // shape is Square
}

in narrow ด้วยการดูว่า member ไหนของ unionประกาศว่ามี property นั้นเลย — ไม่ใช่ดูว่า property นั้นมีอยู่จริงตอน runtime หรือเปล่า แปลว่ามันทำงานได้แม้ property จะเป็น optional ตราบใดที่มีแค่ฝั่งเดียวของ union ที่ประกาศมัน:

interface Cat { meow(): void }
interface Dog { bark(): void; leash?: string }
function speak(pet: Cat | Dog) {
if ("leash" in pet) {
pet.bark(); // pet narrowed to Dog -- because only Dog even declares leash
}
}

อันนี้ narrow เป็น Dog แม้กับ Dog ที่ leash ตอนนี้เป็น undefined — in เช็ค shape ที่ประกาศไว้ใน typeไม่ใช่ค่าจริง และมีแค่ Dog เท่านั้นที่มี key leash เลย ไม่ว่าจะ optional หรือไม่ ถ้าทั้งสอง member ประกาศ optional property ตัวเดียวกัน in อย่างเดียวแยกไม่ออก ต้องมี discriminant จริง ๆ

การเทียบสองตัวแปรด้วย === narrow ทั้งคู่ให้เหลือแค่สิ่งที่มีร่วมกัน — ไม่ใช่แค่ฝั่งเดียวเป็น literal:

function compare(a: string | number, b: string | boolean) {
if (a === b) {
// only string is common to both unions -- narrows both to string
a.toUpperCase();
b.toUpperCase();
}
}

ทั้ง a และ b ไม่ใช่ literal เลย นี่จึงไม่ใช่กรณี if (x === "a") — compiler ให้เหตุผลว่า a === b เป็น true ได้ก็ต่อเมื่อทั้งคู่ถือ value จากส่วนที่ตัดกันของ possible type ทั้งสอง คือ string ตัวแปรทั้งสองตัว narrow ไปด้วยกันใน if แม้จะไม่ได้เทียบกับค่าคงที่เลยสักตัว

วิธีที่สะอาดที่สุดในการ 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 is the circle member
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:

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;
case "square": return s.side ** 2;
case "rect": return s.width * s.height;
default:
const _exhaustive: never = s; // compiles now — every member is handled
return _exhaustive;
// add a new Shape member and this line starts erroring, pointing at the gap
}
}

ใน 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 narrowed to string by the guard
}
}

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

สัญญานั้นไม่เคยถูกเช็คกับ body ของ function เลย — compiler เชื่อ signature ไม่ใช่ implementation type-guard function ที่โกหก compile ผ่านโดยไม่มี error เลย:

function isString(x: unknown): x is string {
return true; // lies -- always claims true, no real check
}
function shout(x: unknown) {
if (isString(x)) {
console.log(x.toUpperCase()); // trusted -- x is "string" here
}
}
// @run
function isString(x: unknown): x is string {
return true; // lies -- always claims true, no real check
}
function shout(x: unknown) {
if (isString(x)) {
console.log(x.toUpperCase()); // trusted -- x is "string" here
}
}
shout(42); // not a string at all

รันจริงแล้ว crash ตรงจุดที่ compiler บอกว่าปลอดภัย:

TypeError: x.toUpperCase is not a function

นี่คือคำเตือนหลักของคอร์สในเวอร์ชันที่คมที่สุด: type annotation ไหนก็ตาม — รวมถึง predicate — คือคำกล่าวอ้าง ไม่ใช่บทพิสูจน์ type guard ที่เขียนเองหรือ AI เขียนให้ น่าเชื่อถือแค่ที่ logic จริงของมันเท่านั้น อ่าน body ของ custom x is T function เสมอก่อนจะเชื่อว่ามัน narrow ไปเป็นอะไร

ญาติของ 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 is string from here on — the assert removed null
}

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

ใต้ strict (เจาะจงคือ useUnknownInCatchVariables เปิดโดย default ตั้งแต่ TypeScript 4.4 เมื่อไหร่ก็ตามที่ strict เปิด) exception ที่ catch ได้ type เป็น unknown ไม่ใช่ any — เพราะ JavaScript ให้ throw อะไรก็ได้จริง ๆ compiler เลยปฏิเสธที่จะสันนิษฐานว่ามัน shape เหมือน Error:

// @expect-error TS18046
try {
JSON.parse("{");
} catch (e) {
console.log(e.message); // ❌ 'e' is of type 'unknown'.
}

narrow มันแบบเดียวกับที่ narrow unknown ตัวอื่น ที่พบบ่อยที่สุดคือด้วย instanceof:

try {
JSON.parse("{");
} catch (e) {
if (e instanceof Error) {
console.log(e.message); // ✅ narrowed from unknown
}
}

นี่คือการประยุกต์ใช้ theme ทั้งหมดของโมดูลนี้แบบตรง ๆ ในชีวิตประจำวัน: catch ยื่น type ที่กว้างที่สุดให้โดยตั้งใจ และ narrowing คือสิ่งที่เปลี่ยนมันกลับเป็นอะไรที่คุณเรียก .message ได้อย่างปลอดภัย

guard ง่าย ๆ ไม่ต้องเขียน x is T เองอีกแล้ว เมื่อ function return boolean ที่ เกี่ยวกับ parameter ของมัน TypeScript จะ infer predicate ให้:

interface Bird { name: string }
declare const countries: string[];
declare const nationalBirds: Map<string, Bird>;
function isBirdReal(bird: Bird | undefined) {
return bird !== undefined;
}
// inferred: (bird: Bird | undefined) => bird is Bird
const birds = countries
.map((c) => nationalBirds.get(c))
.filter((b) => b !== undefined);
// ^? Bird[] — was (Bird | undefined)[] before 5.5

compiler จะ infer predicate ก็ต่อเมื่อครบทั้งสี่ข้อ: function ไม่มี return-type annotation, มี return เดียว (และไม่มี implicit return undefined), boolean ที่ return ผูกกับ narrowing check บน parameter และ parameter ไม่ถูก assign ใหม่ อะไรที่ซับซ้อนกว่านั้น — หลาย return, check บน property ของตัวแปรที่ mutable — ยังต้องเขียน x is T เอง

สามการปรับปรุงใหม่ ๆ ที่ลบจังหวะ “ทำไมมันไม่ narrow?” ที่เจอบ่อย:

  • switch (true) (5.3) แต่ละ case expression narrow เหมือนเป็น if:

    declare const x: unknown;
    switch (true) {
    case typeof x === "string": x.toUpperCase(); break;
    case x instanceof Date: x.getTime(); break;
    }
  • closure หลัง assignment สุดท้าย (5.4) const หรือ let/parameter ที่ไม่ถูก assign ใหม่หลังจากนั้น จะรักษา type ที่ narrow แล้วไว้ใน callback ที่สร้างหลัง check:

    function getUrls(url: string | URL, names: string[]) {
    if (typeof url === "string") url = new URL(url);
    return names.map((n) => new URL(n, url)); // url: URL — no cast needed
    }
  • constant indexed access (5.5) obj[key] narrow ได้เมื่อทั้ง obj และ key เป็น const โดยพฤตินัย:

    function f(obj: Record<string, unknown>, key: string) {
    if (typeof obj[key] === "string") obj[key].toUpperCase(); // ✅
    }

อีกด้านของ narrowing: เงื่อนไขที่ ไม่มีทาง narrow ได้แทบจะเป็น bug เสมอ และ 5.6 ทำให้หลายกรณีเป็น error regex หรือ arrow function เปล่า ๆ ใน if เป็น truthy ตลอด; ?? ที่ฝั่งซ้ายไม่มีทาง nullish คือ unreachable:

// @expect-error TS2872 TS2869
if (/0x[0-9a-f]/) { } // ❌ this kind of expression is always truthy
if ((x: number) => 0) { } // ❌ (you meant x >= 0) — an arrow function is always truthy too
function f(value: number, max: number) {
return value < max ?? 100; // ❌ right operand of ?? is unreachable
}

literal true, false, 0 และ 1 ยังใช้ได้ (while (true) เป็นสำนวนปกติ) regex literal ก็ถูกตรวจ syntax ตั้งแต่ 5.5 ด้วย วงเล็บไม่ครบหรือ backreference ไปยัง group ที่ไม่มีจึงเป็น compile error ไม่ใช่เซอร์ไพรส์ตอน runtime

ข้อจำกัดของ narrowing: หายข้าม closure, มองโลกในแง่ดีข้าม await

หัวข้อที่มีชื่อว่า “ข้อจำกัดของ narrowing: หายข้าม closure, มองโลกในแง่ดีข้าม await”

narrowing เป็น property ของ control-flow graph แบบ linear ที่ compiler สร้างให้ function body เดียว — และ graph นั้นมี edge จริงที่มันมองไม่เห็นข้ามไปได้

หายข้าม closure narrowing บน property ของ object ไม่รอดเมื่อถูกอ่านข้างใน nested function เพราะ function นั้นอาจรันเมื่อไหร่ก็ได้ทีหลัง หลังจากอะไรบางอย่างไป mutate property นั้นแล้ว:

// @expect-error TS2339
interface State { id: string | number }
function handleState(state: State) {
if (typeof state.id === "string") {
setTimeout(() => {
state.id.toUpperCase(); // property narrowing does not survive a closure
}, 0);
}
}

ทางแก้คือ capture ค่าที่ narrow แล้วเข้า local const ก่อน สร้าง closure — const reassign ไม่ได้เลย type ที่ narrow แล้วของมันจึงพกเข้า nested function ได้อย่างปลอดภัย:

interface State { id: string | number }
function handleState(state: State) {
if (typeof state.id === "string") {
const id = state.id; // capture the narrowed value in a local const
setTimeout(() => {
id.toUpperCase(); // ✅ id is a const, never reassigned — narrowing survives
}, 0);
}
}

มองโลกในแง่ดีข้าม await await ไม่ได้สร้าง function scope ใหม่ narrowing เลยรอดข้ามมันได้ในเชิง syntax — แต่นั่นจริง ๆ แล้ว unsound เพราะ call ที่ await อยู่รันโค้ดอะไรก็ได้ที่อาจ mutate ค่าที่ narrow ไว้ระหว่างที่ function ของคุณถูกพักไว้ และ compiler ไม่มีทางรู้เลย:

// @run
interface State { id: string | number }
async function corrupt(state: State) {
state.id = 42; // runs while the caller is suspended at its own await
}
async function run(state: State) {
if (typeof state.id === "string") {
await corrupt(state); // TS has no idea this mutates state.id
console.log(state.id.toUpperCase()); // still typed string here -- but is it?
}
}
run({ id: "abc" });

โค้ดนี้ compile ผ่านโดยไม่มี error เลย — TypeScript ไม่มี interprocedural analysis เลยไม่รู้เลยว่า corrupt แตะ state.id ด้วย ยังคงเชื่อ narrowing จากก่อน await ต่อไป รันจริงแล้ว crash:

TypeError: state.id.toUpperCase is not a function

บทเรียนนี้ generalize ได้: narrowing น่าเชื่อถือแค่ที่ความมั่นใจของ compiler ว่าไม่มีอะไรอื่นรันแทรกระหว่างนั้น closure อาจรันทีหลัง — narrowing หายไปโดยตั้งใจ แบบระมัดระวัง await รัน ตอนนี้เลย ใน function เดียวกัน narrowing เลยรอดแบบมองโลกในแง่ดี — ถึงแม้โค้ดจริง รวมถึงโค้ดที่คุณควบคุมไม่ได้ จะมีโอกาส invalidate มันระหว่างนั้น re-check (หรือ re-narrow) ค่าที่ share และ mutable ได้ทันทีหลัง await แทนที่จะเชื่อ check ที่เกิดขึ้นก่อนหน้ามัน

Narrowing survives await, but it's optimistic

interface State { id: string | number }
async function corrupt(state: State) {
state.id = 42; // runs while the caller is suspended at its own await
}
async function run(state: State) {
if (typeof state.id === "string") {
await corrupt(state); // TS has no idea this mutates state.id
console.log(state.id.toUpperCase()); // still typed string here -- but is it?
}
}
run({ id: "abc" });
Open in TS Playground
flowchart TD
  check["A runtime check the compiler understands
(typeof, instanceof, in, equality, discriminant)"] --> cfa["Tracked in the linear control-flow graph
of THIS function body"]
  cfa --> same["Used later in the SAME graph
(if/else, switch, after an await)"] --> trusted["Narrowing holds"]
  cfa --> escapes["Read inside a NESTED function
(closure, setTimeout, event handler)"] --> lost["Narrowing lost -- could run any time"]
  guard["A custom function returning x is T"] --> sig["Compiler trusts the SIGNATURE"]
  sig -.->|"never checks"| body["...not the function body"]
Narrowing คือคำกล่าวอ้างที่ compiler เชื่อ เท่าที่มันมองเห็นเท่านั้น

narrowing rule ทุกตัวในบทนี้คือ compiler ติดตามสิ่งเดียว: คำกล่าวอ้างหนึ่งอัน และมันเดินทางไปได้ไกลแค่ไหนก่อนที่อะไรบางอย่างจะ invalidate มัน typeof check คือคำกล่าวอ้างที่ถูกติดตามแม่นยำผ่าน flow แบบ linear ของ function เดียว type predicate คือคำกล่าวอ้างที่ compiler เชื่อจาก signature เฉยๆ ไม่เคยตรวจกับ body เลย await ไม่ได้ตัดจบ flow คำกล่าวอ้างเลยรอดข้ามมันได้ — แบบมองโลกในแง่ดี เพราะโค้ดจริงรันแทรกระหว่างนั้น closure เริ่ม flow ใหม่ ที่อาจรันเมื่อไหร่ก็ได้ compiler เลยระมัดระวังปฏิเสธที่จะพกคำกล่าวอ้างเก่าเข้าไป ไม่มีอะไรในนี้เป็นเรื่องสุ่ม: มันคือความมั่นใจเท่าที่ static analysis ของ function body เดียว อ่านจากบนลงล่าง ให้ได้จริง ๆ

เทคนิคได้อะไรเสียอะไร
built-in operator (typeof, instanceof, in, equality)ไม่มีต้นทุน ไม่ต้องเขียน code เอง compiler verify กับ check จริงครอบแค่ pattern ที่ compiler รู้จักโดยธรรมชาติเท่านั้น
discriminated union + exhaustive switchvariant ใหม่คือ compile error จนกว่าจะ handle ครบทุกที่ต้องมี literal tag ร่วมกันทุก variant ตั้งแต่แรก
custom type guard (x is T)ห่อ validation logic อะไรก็ได้ไว้หลัง check ที่ reuse ได้compiler เชื่อ signature ไม่ใช่ body — guard ที่ผิดหรือไม่ครบ compile ผ่านสะอาด
assertion function (asserts x is T)narrow ให้ตลอด scope ที่เหลือ ไม่ใช่แค่ branch เดียวมีปัญหาความเชื่อใจแบบเดียวกับ type guard บวก throw แทนที่จะ branch
capture เข้า const ก่อน closureคืน narrowing ที่ closure จะทำหายไปกลับมาตัวแปร local เพิ่มหนึ่งตัวต่อค่าที่ narrow แล้วที่ใช้ใน callback
  • function ที่ return x is T ถูก compiler verify ว่าเช็คตามที่มันกล่าวอ้างจริง ไม่จริง — predicate ถูกเชื่อจาก signature เพียงอย่างเดียว guard ที่ return true เสมอ หรือเช็คผิดอย่าง compile ผ่านโดยไม่มี error เลย
  • narrow ตัวแปรก่อน await ยังปลอดภัยหลังจากนั้น มันถูกรักษาไว้ในเชิง syntax แต่ไม่ได้ปลอดภัยจริง — operation ที่ await อยู่รันโค้ดที่อาจ mutate ค่านั้นระหว่างที่ function ของคุณถูกพักไว้ compiler มองไม่เห็นเรื่องนั้นเลย
  • narrow ตัวแปรก่อนสร้าง closure จะรอดข้างในนั้น ไม่รอด สำหรับ property access — closure อาจรันเมื่อไหร่ก็ได้ทีหลัง compiler เลยทิ้ง narrowing แทนที่จะเสี่ยงว่ามัน stale local const ที่ capture ไว้ก่อนคือทางแก้
  • in ทำงานได้ก็ต่อเมื่อ property มีอยู่จริงตอน runtime มัน narrow จากการดูว่า union member ไหนประกาศว่ามี property นั้นใน type ของมัน เลยทำงานได้แม้กับ optional property ที่ตอนนี้เป็น undefined ตราบใดที่มีแค่ member เดียวที่ประกาศ key นั้นเลย

ก่อนเชื่อ narrowing logic ที่ generate มา ให้เช็คสิ่งเหล่านี้:

  • type-guard function (x is T) ที่ body เช็คอะไรบางอย่างที่อ่อนกว่าที่มันกล่าวอ้างมาก — typeof x === "object" && x !== null แล้ว return x is { id: string; name: string } เป็น shape ที่พบบ่อย และมัน compile ผ่านโดยไม่มี error เลยทั้งที่กล่าวอ้างมากกว่าที่ verify จริง
  • ค่าที่ narrow ไว้ก่อน await แล้วใช้ทันทีหลังจากนั้นโดยไม่ re-check — โดยเฉพาะถ้าอะไรที่ await อยู่มีโอกาสแตะ shared object หรือ module-level state เดียวกัน
  • ค่าที่ narrow แล้วถูกอ่านข้างใน setTimeout, event handler หรือ callback อื่นที่ประกาศข้างใน branch ที่ narrow ไว้ — ถ้าเป็น property access (ไม่ใช่ local ที่ capture ไว้) type ตรงนั้นมักจะกว้างกว่าที่โค้ดรอบข้างบอกเป็นนัย
  • assertion function หรือ type guard ที่ใช้ validate ข้อมูลข้าม boundary จริง (JSON ที่ parse มา, response จาก fetch) ด้วย check ที่ยืนยันแค่ “เป็น object” ไม่ใช่ shape ที่ต้องการจริง

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

function isValidUser(x: unknown): x is { id: string; name: string } {
return typeof x === "object" && x !== null;
}
function greet(input: unknown) {
if (isValidUser(input)) {
console.log(`Hello, ${input.name}`);
}
}
greet({ id: "1" });
เฉลย

โค้ดนี้ compile ผ่านโดยไม่มี error เลย และไม่ crash ด้วย — มันแค่พิมพ์ Hello, undefined แบบเงียบ ๆ isValidUser กล่าวอ้าง predicate ที่แข็งแรงกว่ามากคือ x is { id: string; name: string } แต่ body ของมันเช็คแค่ “เป็น non-null object” ซึ่ง { id: "1" } ผ่านโดยไม่เคยมี property name เลย เพราะ compiler ไม่เคยเช็ค body ของ type guard กับ predicate ที่มันกล่าวอ้าง greet เลยเชื่อว่า input.name เป็น string — จริง ๆ มันเป็น undefined และการ interpolate ด้วย template literal ก็แปลงมันเป็น string ให้เฉย ๆ โดยไม่ throw ทางแก้คือทำให้ runtime check ของ guard ตรงกับที่มันกล่าวอ้าง: typeof x === "object" && x !== null && "id" in x && "name" in x && typeof (x as any).name === "string" หรือใช้ library validation จริงที่รักษา check กับ type ให้ตรงกันเสมอ

control-flow narrowing คืออะไร?
อะไรนิยาม discriminated union?
การ assign เข้า never ใน branch default ช่วยอะไร?
function ที่ return x is string (type predicate) ทำอะไร?
assertion function asserts cond ทำอะไรหลังถูกเรียก?
TypeScript 5.5+ infer type predicate ให้ function ที่ไม่ได้เขียน x is T เมื่อไร?
โค้ดนี้ compile ผ่านไหม แล้วเกิดอะไรขึ้นตอนรัน?
function isString(x: unknown): x is string {
  return true;
}

function shout(x: unknown) {
  if (isString(x)) {
    console.log(x.toUpperCase());
  }
}

shout(42);
run compile ผ่านไหม และ narrowing หลัง await corrupt(state) ปลอดภัยจริงหรือเปล่า?
interface State { id: string | number }
async function corrupt(state: State) {
  state.id = 42;
}
async function run(state: State) {
  if (typeof state.id === "string") {
    await corrupt(state);
    state.id.toUpperCase();
  }
}