Skip to content

บทที่ 07 — Type Narrowing (การถาม type)

← บทที่ 06 | สารบัญ | บทที่ 08: Class →

⏱ ใช้เวลา 2–3 ชั่วโมง


บทนี้เรียนอะไร

เราเจอคำว่า "narrowing" มาหลายครั้งในบทก่อน ๆ บทนี้จะลงลึกเต็มที่

Narrowing (การทำให้แคบลง — "ถาม type จนได้คำตอบที่เจาะจง") คือทักษะที่ใช้ "ทุกวัน" ในการเขียน TypeScript จริง เพราะข้อมูลในโลกจริงมักมาเป็น type ที่ "กว้าง" หรือ "ไม่แน่นอน":

  • ค่าที่อาจเป็น string หรือ number
  • ค่าที่อาจเป็น User หรือ null
  • ข้อมูลจาก API ที่เป็น unknown

narrowing คือเทคนิคที่ทำให้เรา "ถาม" จนรู้ว่าจริง ๆ แล้วมันคือ type อะไร เพื่อจะได้ใช้งานมันได้อย่างปลอดภัย


Part 1: ทำความเข้าใจปัญหา ก่อนเข้าเรื่อง

1.1 ปัญหาของ type ที่ "กว้าง"

สมมติเรามีฟังก์ชันที่รับค่าได้ทั้ง string และ number:

typescript
function formatValue(value: string | number) {
    return value.toUpperCase();
    //           ↑ ❌ Error! Property 'toUpperCase' does not exist on type 'number'
}

ทำไม error? เพราะ value มี type เป็น string | number — มันอาจเป็น string หรือ number ก็ได้ และ TypeScript จะยอมให้เราใช้ เฉพาะสิ่งที่ทั้งสอง type ทำได้ร่วมกัน เท่านั้น

toUpperCase() มีแค่ใน string ไม่มีใน number — TypeScript จึงไม่ยอม เพราะถ้า value บังเอิญเป็น number ขึ้นมา โปรแกรมจะพัง

1.2 ทางออก — "ถาม" ก่อนใช้

ทางออกคือ — เราต้อง "ถาม" ก่อนว่า value เป็นอะไรกันแน่ แล้วค่อยใช้งานในบล็อกที่เรารู้แน่นอนแล้ว:

typescript
function formatValue(value: string | number) {
    if (typeof value === "string") {
        // ★ ในบล็อกนี้ TypeScript "รู้" แล้วว่า value เป็น string เท่านั้น
        return value.toUpperCase();   // ✅ ใช้ได้!
    } else {
        // ★ ในบล็อกนี้ TypeScript รู้ว่า value เป็น number เท่านั้น
        return value.toFixed(2);      // ✅ ใช้ได้!
    }
}

นี่แหละคือ narrowing — เราเขียนเงื่อนไขที่ "ถาม type" และเมื่อ TypeScript เห็นเงื่อนไขนั้น มันจะ "ทำให้ type แคบลง" ในบล็อกที่เกี่ยวข้อง จาก string | number (กว้าง) เหลือ string หรือ number (แคบ)

1.3 Control Flow Analysis (การวิเคราะห์โฟลว์การทำงาน) — TypeScript "อ่านโค้ดตามเรา"

สิ่งที่ทำให้ narrowing ทำงานได้คือความสามารถของ TypeScript ที่เรียกว่า Control Flow Analysis — มัน"เดินตามโฟลว์ของโค้ด" เหมือนที่คนอ่าน และอัปเดตความเข้าใจเรื่อง type ไปเรื่อย ๆ:

typescript
function example(value: string | number) {
    // ตรงนี้ TypeScript คิดว่า value เป็น: string | number

    if (typeof value === "number") {
        // ตรงนี้ TypeScript คิดว่า value เป็น: number
        return;
    }

    // ★ มาถึงตรงนี้ได้ แปลว่า value ไม่ใช่ number → TypeScript คิดว่า: string
    value.toUpperCase();   // ✅ ใช้ได้ เพราะ TypeScript รู้ว่าเหลือแค่ string
}

สังเกตบรรทัดสุดท้าย — เราไม่ได้เขียน if อะไรเลย แต่ TypeScript รู้เองว่า value เป็น string เพราะ "ถ้าเป็น number มันคงreturnไปแล้ว — มาถึงตรงนี้ได้ต้องเป็น string เท่านั้น" นี่คือความฉลาดของ Control Flow Analysis

ทีนี้มาดูวิธี "ถาม type" แบบต่าง ๆ


Part 2: typeof Narrowing — ถามชนิดพื้นฐาน

typeof เป็นตัวดำเนินการของ JavaScript ที่บอก "ชนิดพื้นฐาน" ของค่า ใช้ narrow ได้:

typescript
function process(value: string | number | boolean) {
    if (typeof value === "string") {
        value.toUpperCase();      // value: string
    } else if (typeof value === "number") {
        value.toFixed(2);         // value: number
    } else {
        value === true;           // value: boolean
    }
}

typeof คืนค่าเป็น string ได้ค่าเหล่านี้: "string", "number", "boolean", "bigint", "symbol", "undefined", "object", "function"

2.1 กับดักของ typeof ที่ต้องระวัง

JavaScript มีพฤติกรรมแปลก ๆ ที่ติดมาตั้งแต่อดีต — ต้องรู้ไว้:

typescript
typeof null         // "object"  ← 😱 ไม่ใช่ "null"! (กับดักประวัติศาสตร์ของ JS)
typeof []           // "object"  ← array ก็เป็น "object"
typeof {}           // "object"

ดังนั้น typeof แยกไม่ออก ระหว่าง null, array, และ object ธรรมดา — ถ้าต้องเช็กพวกนี้ต้องใช้วิธีอื่น (เช่น === null, Array.isArray())

⚠️ จำกฎเหล็ก: ก่อนเช็ก property ของ object ใด ๆ ที่มาจาก union เช่น object | null ให้ narrow null ออกก่อนเสมอ — เพราะ typeof null === "object" ทำให้เงื่อนไข typeof x === "object" ผ่านทั้งกรณีที่ x เป็น null ด้วย!

typeof เหมาะกับการเช็ก primitive type (string, number, boolean) เป็นหลัก


Part 3: instanceof Narrowing — ถามว่ามาจาก class ไหน

เมื่อค่าเป็น object ที่สร้างจาก class เราใช้ instanceof เพื่อถามว่า "มันเป็น instance ของ class ไหน":

typescript
class Dog {
    bark() { console.log("โฮ่ง"); }
}
class Cat {
    meow() { console.log("เหมียว"); }
}

function makeSound(animal: Dog | Cat) {
    if (animal instanceof Dog) {
        animal.bark();   // animal: Dog
    } else {
        animal.meow();   // animal: Cat
    }
}

3.1 ใช้งานบ่อยที่สุด — narrow error ใน catch

instanceof ใช้บ่อยที่สุดตอนจัดการ error เพราะใน TypeScript สมัยใหม่ ตัวแปร error ใน catch มี type เป็น unknown (เราไม่รู้ว่าโค้ดจะโยนอะไรมา):

typescript
try {
    riskyOperation();
} catch (error) {
    // error มี type เป็น unknown — ใช้งานตรง ๆ ไม่ได้
    // error.message;  ❌ Error!

    if (error instanceof Error) {
        // ในนี้ error เป็น Error แล้ว — เข้าถึง .message ได้
        console.log("เกิดข้อผิดพลาด:", error.message);
    } else {
        console.log("ข้อผิดพลาดที่ไม่รู้จัก:", error);
    }
}

📌 จำ pattern นี้ให้ขึ้นใจ — ทุกครั้งที่ catch (error) ให้เช็ก error instanceof Error ก่อนเข้าถึง .message เสมอ


Part 4: in Narrowing — ถามว่ามี property นี้ไหม

เมื่อค่าเป็น object ที่ไม่ได้มาจาก class (เป็นแค่ object ธรรมดา) เราใช้ in เพื่อถามว่า "object นี้มี property ชื่อนี้ไหม":

typescript
type Bird = { fly(): void; layEggs(): void };
type Fish = { swim(): void; layEggs(): void };

function move(animal: Bird | Fish) {
    if ("fly" in animal) {
        animal.fly();    // animal: Bird (เพราะมี property "fly")
    } else {
        animal.swim();   // animal: Fish
    }
}

"fly" in animal แปลว่า "animal มี property ชื่อ fly ไหม" — ถ้ามี TypeScript ก็รู้ว่ามันต้องเป็น Bird

in มีประโยชน์เมื่อ object สอง type ต่างกันที่ "property ที่มี" — แต่ถ้าจะให้ดีที่สุด ใช้ discriminated union (Part 6) จะชัดเจนกว่า


Part 5: Narrowing ด้วยการเปรียบเทียบและความ truthy

5.1 เช็ก null / undefined

นี่คือ narrowing ที่ใช้บ่อยที่สุดในชีวิตจริง — เพราะ strictNullChecks บังคับให้เราจัดการ null/undefined เสมอ:

typescript
function greet(name: string | null) {
    if (name === null) {
        return "ไม่มีชื่อ";
    }
    // มาถึงตรงนี้ name ไม่ใช่ null แล้ว → name: string
    return `สวัสดี ${name.toUpperCase()}`;
}

5.2 Truthy Narrowing — เช็กว่า "มีค่า" ไหม

ใน JavaScript เราเช็กแบบ "ถ้ามีค่า" ด้วย if (x) ได้เลย TypeScript ก็ narrow ตาม:

typescript
function printLength(text: string | null | undefined) {
    if (text) {
        // ในนี้ text เป็น string (ไม่ใช่ null และไม่ใช่ undefined)
        console.log(text.length);
    }
}

⚠️ กับดักสำคัญของ truthy narrowing — ใน JavaScript ค่าที่ถือว่า "falsy" (ทำให้ if เป็นเท็จ) ไม่ได้มีแค่ null/undefined แต่รวมถึง:

  • "" (string ว่าง)
  • 0, -0, 0n (เลขศูนย์ทุกแบบ รวม BigInt zero)
  • false, NaN
  • document.all (กรณีพิเศษเก่าของเบราว์เซอร์ ไม่ค่อยเจอ)

ดังนั้น if (text) จะ "ตกหล่น" กรณี string ว่าง "" ไปด้วย:

typescript
function check(text: string | null) {
    if (text) {
        // กรณี text === "" จะ "ไม่เข้า" บล็อกนี้!
        // ทั้งที่ "" ก็เป็น string ที่ valid
    }
}

ถ้าคุณ "ตั้งใจ" จะเช็กแค่ null/undefined (แต่ยอมรับ "" และ 0) ให้ใช้ != null แทน:

typescript
if (text != null) {
    // เข้าบล็อกนี้ทุกกรณีที่ text ไม่ใช่ null และไม่ใช่ undefined
    // รวมถึงกรณี text === ""
}

⚠️ != null ใช้ loose equality (เครื่องหมาย != ตัวเดียว ไม่มี = สาม) ทบทวนสั้น ๆ: loose equality (==/!=) จะ "แปลง type ให้ตรงกันก่อนค่อยเทียบ" ส่วน strict equality (===/!==) จะเทียบตรง ๆ โดยไม่แปลง type เลย

ในกรณีนี้ x != null มีความหมายพิเศษ: มันเทียบเท่ากับ x !== null && x !== undefined (เช็กสองเงื่อนไขพร้อมกันในคำสั่งเดียว) นี่เป็นหนึ่งในไม่กี่ที่ที่ใช้ loose equality โดยตั้งใจ เพราะได้ผลเหมือนเช็ก strict สองเงื่อนไขรวด — ต่างจาก !== (strict) เดี่ยว ๆ ที่เช็กได้แค่ค่าเดียวต่อครั้ง

📌 ข้อควรระวังเรื่อง lint: ESLint rule eqeqeq แบบ default มักฟ้อง (error) การใช้ !=/== ทุกกรณี รวมถึง x != null ด้วย ถ้าจะใช้ pattern นี้ในทีมที่เข้มงวดเรื่อง lint ให้ตั้งค่า eqeqeq: ["error", "always", { "null": "ignore" }] เพื่ออนุญาตเฉพาะกรณีเทียบกับ null หรือจะเขียนแบบ strict เต็ม ๆ x !== null && x !== undefined แทนก็ได้ถ้าไม่อยากยุ่งกับ config


Part 6: Discriminated Union (ยูเนียนที่มีตัวแยกแยะ) Narrowing — pattern หลัก

📎 คำศัพท์: discriminated union = "ยูเนียนที่มีตัวแยกแยะ" — union ของหลาย object ที่ทุกตัวมี field "ตัวแยกแยะ" (discriminator) เป็น literal type ทำให้ TypeScript narrow ได้ง่าย

นี่คือหัวใจของบท เราเกริ่นไปแล้วในบทที่ 04 — บทนี้จะดูว่ามัน narrow ยังไง

6.1 ทบทวน

Discriminated union คือ union ของหลาย object ที่ทุกตัวมี field "discriminator" เป็น literal type:

typescript
type NetworkState =
    | { status: "loading" }
    | { status: "success"; response: string }
    | { status: "failed"; errorCode: number };
//      ↑ "status" คือ discriminator

6.2 narrow ด้วยการเช็ก discriminator

เมื่อเราเช็กค่าของ discriminator (status) TypeScript จะ narrow ทั้ง object ให้:

typescript
function handleState(state: NetworkState) {
    if (state.status === "success") {
        // narrow แล้ว → state เป็น { status: "success"; response: string }
        console.log(state.response);   // ✅ เข้าถึง response ได้
    }

    if (state.status === "failed") {
        // narrow แล้ว → state เป็น { status: "failed"; errorCode: number }
        console.log(state.errorCode);  // ✅ เข้าถึง errorCode ได้
    }
}

6.3 ใช้กับ switch (วิธีที่นิยมที่สุด)

เมื่อ union มีหลายกรณี switch อ่านง่ายกว่า if:

typescript
function describe(state: NetworkState): string {
    switch (state.status) {
        case "loading":
            return "กำลังโหลด...";
        case "success":
            return `สำเร็จ: ${state.response}`;    // narrow → เข้าถึง response ได้
        case "failed":
            return `ล้มเหลว (รหัส ${state.errorCode})`;  // narrow → เข้าถึง errorCode ได้
    }
}

6.4 Action Pattern — ใช้ใน state management

pattern นี้คือหัวใจของ Redux และ useReducer ใน React — "action" คือ discriminated union:

typescript
type CounterAction =
    | { type: "increment"; amount: number }
    | { type: "decrement"; amount: number }
    | { type: "reset" };

function counterReducer(count: number, action: CounterAction): number {
    switch (action.type) {
        case "increment":
            return count + action.amount;   // เข้าถึง amount ได้
        case "decrement":
            return count - action.amount;
        case "reset":
            return 0;
    }
}

Part 7: Type Predicate (ฟังก์ชันบอกชนิด — ที่เรานิยามเอง) — สร้าง "ตัวถาม type" ของเราเอง

7.1 ปัญหา

บางครั้งเงื่อนไขการเช็ก type ซับซ้อน เราอยากแยกออกมาเป็นฟังก์ชัน แต่ถ้าเขียนตรง ๆ TypeScript จะไม่ narrow ให้:

typescript
function isString(value: unknown): boolean {
    return typeof value === "string";
}

function process(value: unknown) {
    if (isString(value)) {
        value.toUpperCase();   // ❌ Error! TypeScript ยังคิดว่า value เป็น unknown
    }
}

ทำไม? เพราะ isString คืนแค่ boolean — TypeScript เห็นว่า "ฟังก์ชันนี้คืน true/false" แต่มันไม่รู้ว่า "true แปลว่า value เป็น string"

7.2 ทางออก — Type Predicate

เราต้องเปลี่ยน return type จาก boolean เป็น type predicate ซึ่งเขียนว่า พารามิเตอร์ is Type:

typescript
function isString(value: unknown): value is string {
    //                              ↑ type predicate — "ถ้าคืน true แปลว่า value เป็น string"
    return typeof value === "string";
}

function process(value: unknown) {
    if (isString(value)) {
        value.toUpperCase();   // ✅ ตอนนี้ TypeScript narrow ให้แล้ว!
    }
}

value is string เป็นการบอก TypeScript ว่า "ฟังก์ชันนี้ไม่ได้คืนแค่ boolean ธรรมดา — ถ้ามันคืน true ให้ถือว่าพารามิเตอร์ value เป็น string"

ฟังก์ชันแบบนี้เรียกว่า type guard (ตัวคุมชนิด) ที่เรานิยามเอง (user-defined type guard)

7.3 ประโยชน์จริง — ใช้กับ .filter()

type predicate มีประโยชน์มากกับ .filter() — ปกติ .filter() ไม่เปลี่ยน type ของ array แต่ถ้าใช้ type predicate มันเปลี่ยนได้:

typescript
const mixed: (string | number)[] = ["a", 1, "b", 2, "c"];

// filter ธรรมดา — type ยังเป็น (string | number)[]
const filtered1 = mixed.filter(x => typeof x === "string");
// filtered1: (string | number)[]  ← ยังกว้างอยู่ น่าเสียดาย

// filter ด้วย type predicate — type แคบลงเป็น string[]
const filtered2 = mixed.filter((x): x is string => typeof x === "string");
// filtered2: string[]  ✅ แคบลงแล้ว!

📌 อ่านไวยากรณ์ (x): x is string => ...: ส่วน (x) คือพารามิเตอร์ของ arrow function ส่วน : x is string คือ "return type" ที่เป็น type predicate — บอก TypeScript ว่า "ถ้าฟังก์ชันคืน true ให้ถือว่า x เป็น string" จากนั้น => typeof x === "string" คือ body ที่คืน boolean เหมือนปกติ

💡 TS 5.5+ (อนุมาน type predicate อัตโนมัติ): TypeScript เวอร์ชัน 5.5 ขึ้นไปสามารถเดา type predicate ให้อัตโนมัติได้ในบางกรณี เมื่อ body ของ filter callback ตรวจชนิดอย่างชัดเจน — แต่การเขียน (x): x is T กำกับเองยังเป็นวิธีที่ชัวร์และอ่านง่ายที่สุด

7.4 ในงานจริง — Zod ทำให้ดีกว่า

การเขียน type predicate ตรวจสอบ object ซับซ้อนด้วยมือนั้นยาวและพลาดง่าย:

typescript
// เขียนเองยาวมาก และพลาดง่าย
function isUser(x: unknown): x is User {
    return (
        typeof x === "object" && x !== null &&
        "name" in x && typeof (x as any).name === "string" &&
        "age" in x && typeof (x as any).age === "number"
    );
}

ในงานจริง ใช้ Zod (จากบทที่ 00) จะสั้นและปลอดภัยกว่ามาก:

typescript
const result = UserSchema.safeParse(data);
if (result.success) {
    // result.data มี type เป็น User ให้อัตโนมัติ
    use(result.data);
}

Part 8: Assertion Function (ฟังก์ชันยืนยัน — ถ้าผิดจะ throw) — "ถ้าผิดให้พังไปเลย"

Assertion function เป็นญาติของ type predicate — แต่แทนที่จะ "คืน true/false" มันจะ "โยน error ถ้าเงื่อนไขไม่ผ่าน"

⚠️ ข้อจำกัดของไวยากรณ์: asserts ... is ... ใช้ได้กับ function declaration (function f(...)) หรือ method ของ object/class เท่านั้น — เขียนเป็น arrow function ตรง ๆ แบบ const f = (v): asserts v is string => ... ไม่ได้ TypeScript จะ error ทันที (ต้องการ TS 3.7+ ขึ้นไป)

typescript
function assertIsString(value: unknown): asserts value is string {
    //                                   ↑ asserts ... is ...
    if (typeof value !== "string") {
        throw new Error("ค่านี้ไม่ใช่ string");
    }
}

function process(value: unknown) {
    assertIsString(value);
    // ★ มาถึงตรงนี้ได้ แปลว่าผ่าน assert แล้ว → value เป็น string แน่นอน
    value.toUpperCase();   // ✅ ใช้ได้เลย ไม่ต้อง if
}

ความต่างจาก type predicate:

  • type predicate (value is T) — ใช้ใน if เพื่อ "เลือกทาง"
  • assertion function (asserts value is T) — ถ้าผ่าน โค้ดเดินต่อโดย type ถูก narrow แล้ว ถ้าไม่ผ่าน โปรแกรมหยุด (throw)

8.1 ตัวอย่างที่ใช้บ่อย — assert ว่า "มีค่า"

ก่อนจะเข้าใจ assert แบบทั่วไป (generic) ขอเริ่มจากตัวอย่างง่ายสุด — assert ตรง ๆ ว่า "ค่านี้ต้องเป็น string":

typescript
function assertString(value: unknown): asserts value is string {
    if (typeof value !== "string") throw new Error("ต้องเป็น string");
}

const x: unknown = "hello";
assertString(x);
x.toUpperCase();   // ✅ หลัง assert → x เป็น string

จากนั้นมาดูเวอร์ชัน "ทั่วไป" ที่ใช้ได้กับทุกเงื่อนไข — สังเกตว่า return type เป็น asserts condition (ไม่มี is) แปลว่า "ถ้าผ่าน เงื่อนไขที่ส่งมาเป็นจริง":

typescript
function assert(condition: unknown, message: string): asserts condition {
    if (!condition) {
        throw new Error(message);
    }
}

function getUserName(user: User | null): string {
    assert(user, "ต้องมี user");
    // หลัง assert → user เป็น User (ไม่ใช่ null แล้ว)
    return user.name;   // ✅
}

asserts condition ใช้ความรู้ที่ว่า "ถ้าผ่านฟังก์ชันนี้ได้ แปลว่า condition เป็น truthy" — TypeScript จึง narrow ตามเงื่อนไขนั้น (ในที่นี้ narrow user ออกจาก null)


Part 9: Exhaustive Check (ตรวจครบทุกกรณี) — บังคับให้จัดการครบทุกกรณี

เราเกริ่นเรื่องนี้ไว้ในบทที่ 02 (เรื่อง never) ตอนนี้มาดูการใช้งานจริงเต็ม ๆ

9.1 ปัญหา

เมื่อมี discriminated union แล้วเขียน switch — จะเกิดอะไรขึ้นถ้าวันหนึ่งมีคนเพิ่มกรณีใหม่เข้าไปใน union แต่ลืมเพิ่ม case?

⚠️ ตัวอย่างต่อไปนี้ ตั้งใจเขียนให้ยังไม่สมบูรณ์ เพื่อโชว์ปัญหา — ถ้าลองเอาไปวางในโปรเจกต์ที่เปิด strict จริง ๆ TypeScript อาจจะฟ้อง error TS2366: Function lacks ending return statement ทันทีตอน compile (เพราะ switch ไม่มี default และ TypeScript ไม่ยืนยันว่าครบทุก case) นี่คือพฤติกรรมที่ "ถูกต้อง" — มันคือสิ่งที่บทนี้กำลังจะแก้ด้วย exhaustive check ด้านล่าง

typescript
type Shape =
    | { kind: "circle"; radius: number }
    | { kind: "square"; side: number };

function getArea(shape: Shape): number {
    switch (shape.kind) {
        case "circle": return Math.PI * shape.radius ** 2;
        case "square": return shape.side ** 2;
        // ❌ ไม่มี default — TypeScript (strict mode) อาจฟ้อง "lacks ending return statement" ตรงนี้เลย
    }
}

ต่อให้สมมติว่าโค้ดผ่าน compile ได้ (เช่น ปิด noImplicitReturns) ก็ยังมีปัญหาอีกชั้น: ถ้ามีคนเพิ่ม { kind: "triangle"; base: number; height: number } เข้าไปใน Shape — โค้ดจะยังคอมไพล์ผ่านเหมือนเดิม แต่พอเจอ triangle จริงมันจะคืน undefined เงียบ ๆ → บั๊ก

9.2 ทางออก — Exhaustive Check ด้วย never

เราเพิ่ม default case ที่ assign ค่าให้ตัวแปร type never:

typescript
function getArea(shape: Shape): number {
    switch (shape.kind) {
        case "circle": return Math.PI * shape.radius ** 2;
        case "square": return shape.side ** 2;
        default:
            // ★ ถ้าจัดการครบทุก case แล้ว มาถึงนี่ shape จะมี type เป็น never
            const _exhaustiveCheck: never = shape;
            throw new Error(`รูปทรงที่ไม่รู้จัก: ${(shape as { kind: string }).kind}`);
    }
}

มันทำงานยังไง? — ถ้าเราจัดการ circle กับ square ครบแล้ว มาถึง default ได้ก็ต่อเมื่อ shape ไม่ใช่ทั้งสองอย่าง — ซึ่ง "เป็นไปไม่ได้" → TypeScript narrow shape เป็น never

📌 ทำไมต้อง cast (shape as { kind: string }) ในบรรทัดข้อความ error? เพราะ ณ จุดนั้น TypeScript คิดว่า shape เป็น never แล้ว — จะอ่าน .kind ตรง ๆ ไม่ได้ (เพราะ never ไม่มี property ใด ๆ) การ cast นี้จึงมีไว้ "หลอก" TypeScript ให้ยอมอ่าน .kind เพื่อเอาไปแสดงใน error message เท่านั้น ปลอดภัยเพราะโค้ดบรรทัดนี้ไม่ควรถูกรันจริงอยู่แล้ว (ถ้าถูกรัน แปลว่ามี case ตกหล่นจริง ซึ่งเป็นสิ่งที่เราตั้งใจจะจับ)

แต่ถ้ามีคนเพิ่ม triangle เข้าไปใน Shape แล้วลืมเพิ่ม case — มาถึง default ตอนนั้น shape จะยังเป็น { kind: "triangle"; ... } (ไม่ใช่ never) → บรรทัด const _exhaustiveCheck: never = shape จะ error ทันที เพราะ assign object ให้ never ไม่ได้

ผลคือ TypeScript "บังคับ" ให้คุณกลับมาเพิ่ม case ให้ครบ — นี่คือพลังที่ทำให้โค้ดปลอดภัยในระยะยาว

9.3 ทำเป็นฟังก์ชันช่วย

แทนที่จะเขียนซ้ำ ทำเป็นฟังก์ชันช่วย:

typescript
function assertNever(value: never): never {
    throw new Error(`ค่าที่ไม่คาดคิด: ${JSON.stringify(value)}`);
}

function getArea(shape: Shape): number {
    switch (shape.kind) {
        case "circle": return Math.PI * shape.radius ** 2;
        case "square": return shape.side ** 2;
        default:
            return assertNever(shape);   // error ถ้ามี case ตกหล่น
    }
}

Part 10: กับดักของ Narrowing ที่ต้องระวัง

10.1 narrowing "หาย" ใน callback / closure

📎 คำศัพท์: callback (คอลแบ็ก) = ฟังก์ชันที่เราส่งให้ฟังก์ชันอื่นไป "เรียกทีหลัง" เช่น ฟังก์ชันใน setTimeout หรือ .forEach ส่วน closure (โคลเชอร์) = ฟังก์ชันที่ "จำ" ตัวแปรจากขอบเขตด้านนอกที่มันถูกสร้างขึ้นมาได้ แม้จะถูกเรียกทีหลัง (ถ้ายังไม่แม่น ทบทวนได้ที่ JavaScript บทที่ 05 — Functions Deep)

นี่คือกับดักที่มือใหม่งงมาก — TypeScript จะ "ลืม" narrowing เมื่อเข้าไปใน callback:

typescript
function process(value: string | null) {
    if (value === null) return;

    value.toUpperCase();   // ✅ ตรงนี้ TypeScript รู้ว่า value เป็น string

    setTimeout(() => {
        value.toUpperCase();   // ❌ Error! 'value' is possibly 'null'
    }, 1000);
}

ทำไม callback ถึง error? เพราะ callback อาจถูกเรียก "ในอนาคต" — TypeScript ไม่กล้ารับประกันว่าตอนนั้น value จะยังไม่เป็น null (เพราะ value อาจถูกแก้ไปแล้ว) มันจึง "ลืม" narrowing เพื่อความปลอดภัย

วิธีแก้ — copy ค่าใส่ตัวแปร const ใหม่:

typescript
function process(value: string | null) {
    if (value === null) return;

    const safeValue = value;   // copy ใส่ const — const เปลี่ยนค่าไม่ได้
    setTimeout(() => {
        safeValue.toUpperCase();   // ✅ TypeScript มั่นใจ เพราะ const ไม่มีทางเป็น null
    }, 1000);
}

เพราะ safeValue เป็น const มันเปลี่ยนค่าไม่ได้ — TypeScript จึงมั่นใจว่ามันจะเป็น string ตลอดไป

10.2 Optional Chaining ?. ช่วย narrow ได้

?. (optional chaining) เป็นเครื่องมือที่ช่วยจัดการ null/undefined อย่างปลอดภัย:

typescript
type User = { address?: { city?: string } };

function getCity(user: User): string {
    const city = user.address?.city;
    //                       ↑ ถ้า address เป็น undefined ทั้งนิพจน์ได้ undefined ทันที
    // city มี type: string | undefined

    return city ?? "ไม่ระบุ";
    //          ↑ ?? = ถ้าฝั่งซ้าย null/undefined ให้ใช้ฝั่งขวา
}

Part 11: Lab — ลงมือทำ

Lab 1 — narrow หลายแบบ

ฝึก narrow union type หลายชนิดในฟังก์ชันเดียว — เช็ก === null, typeof ทีละชนิด แล้ว TypeScript จะรู้ type ที่แน่นอนในแต่ละ branch (เรียก property ของ type นั้นได้):

typescript
function describe(value: string | number | boolean | null): string {
    if (value === null) {
        return "ไม่มีค่า";
    }
    if (typeof value === "string") {
        return `ข้อความยาว ${value.length} ตัว`;
    }
    if (typeof value === "number") {
        return `ตัวเลข ${value.toFixed(2)}`;
    }
    return `ค่าตรรกะ: ${value ? "จริง" : "เท็จ"}`;
}

Lab 2 — Discriminated union + exhaustive check

ฝึกทำ "exhaustive check" — ใช้ตัวแปร never ใน default case เพื่อให้ TypeScript เตือนตอน compile ถ้ามี method ใหม่ที่ลืมจัดการ (กัน bug เมื่อ union ขยายในอนาคต):

typescript
type Payment =
    | { method: "cash"; amount: number }
    | { method: "card"; amount: number; cardNumber: string }
    | { method: "transfer"; amount: number; bankCode: string };

function assertNever(x: never): never {
    throw new Error(`วิธีจ่ายเงินที่ไม่รู้จัก: ${JSON.stringify(x)}`);
}

function describePayment(payment: Payment): string {
    switch (payment.method) {
        case "cash":
            return `เงินสด ${payment.amount} บาท`;
        case "card":
            return `บัตร ...${payment.cardNumber.slice(-4)} จำนวน ${payment.amount} บาท`;
        case "transfer":
            return `โอนผ่าน ${payment.bankCode} จำนวน ${payment.amount} บาท`;
        default:
            return assertNever(payment);
    }
}

Lab 3 — Type predicate

typescript
type Admin = { role: "admin"; permissions: string[] };
type Member = { role: "member"; joinedAt: Date };
type Account = Admin | Member;

function isAdmin(account: Account): account is Admin {
    return account.role === "admin";
}

const accounts: Account[] = [
    { role: "admin", permissions: ["read", "write"] },
    { role: "member", joinedAt: new Date() },
    { role: "admin", permissions: ["read"] },
];

const admins = accounts.filter(isAdmin);   // type: Admin[]
admins.forEach(a => console.log(a.permissions));   // เข้าถึง permissions ได้

Part 12: Checkpoint

1. Narrowing คืออะไร?

การเขียนเงื่อนไข "ถาม type" เพื่อให้ TypeScript ทำให้ type ของค่าแคบลง จาก type ที่กว้าง/ไม่แน่นอน (เช่น string | number) เหลือ type ที่เจาะจง — เพื่อใช้งานได้อย่างปลอดภัย

2. typeof narrowing มีกับดักอะไร?

typeof null === "object" และ typeof [] === "object"typeof แยก null, array, object ธรรมดาออกจากกันไม่ได้ เหมาะกับเช็ก primitive (string/number/boolean)

3. instanceof ใช้ narrow อะไร และนิยมใช้ตอนไหน?

ใช้เช็กว่า object เป็น instance ของ class ไหน — นิยมใช้มากตอนจัดการ error: if (error instanceof Error) ก่อนเข้าถึง .message

4. Truthy narrowing (if (x)) มีกับดักอะไร?

ค่า falsy ไม่ได้มีแค่ null/undefined แต่รวม "", 0, false ด้วย — if (text) จะตกหล่นกรณี string ว่าง ถ้าต้องการเช็กแค่ null/undefined ใช้ != null

5. Type predicate เขียนยังไง ต่างจาก return boolean ธรรมดายังไง?

เขียน return type เป็น พารามิเตอร์ is Type (เช่น value is string) — บอก TypeScript ว่า "ถ้าฟังก์ชันคืน true ให้ถือว่าพารามิเตอร์เป็น type นั้น" ทำให้ narrow ได้ ต่างจาก return boolean ที่ TypeScript ไม่รู้ความหมาย

6. Assertion function ต่างจาก type predicate ยังไง?

type predicate (x is T) ใช้ใน if เพื่อเลือกทาง assertion function (asserts x is T) ถ้าผ่านโค้ดเดินต่อ (type narrow แล้ว) ถ้าไม่ผ่านโยน error

7. Exhaustive check ด้วย never ทำงานยังไง?

ใส่ const _: never = value ใน default case — ถ้าจัดการครบทุกกรณี value จะเป็น never (assign ได้) แต่ถ้ามีกรณีตกหล่น value จะไม่ใช่ never → error → บังคับให้กลับมาเพิ่ม case

8. ทำไม narrowing หายเมื่อเข้าไปใน callback?

เพราะ callback อาจถูกเรียกในอนาคต — TypeScript ไม่กล้ารับประกันว่าตอนนั้นค่าจะยังไม่เปลี่ยน จึงลืม narrowing เพื่อความปลอดภัย แก้ได้ด้วยการ copy ค่าใส่ตัวแปร const


Part 13: สรุปบทนี้

  • Narrowing = "ถาม type" เพื่อทำให้ type แคบลง ใช้งานได้ปลอดภัย — TypeScript เดินตามโฟลว์โค้ด (Control Flow Analysis)
  • typeof — เช็ก primitive (ระวัง null/array เป็น "object")
  • instanceof — เช็ก class (นิยมใช้กับ error)
  • in — เช็กว่ามี property
  • เช็ก null / truthy — ระวัง "" และ 0 เป็น falsy; ใช้ != null ถ้าต้องการ
  • Discriminated union — pattern หลัก narrow ด้วย discriminator + switch
  • Type predicate (x is T) — ตัวถาม type ที่เราสร้างเอง
  • Assertion function (asserts x is T) — ผ่านก็เดินต่อ ไม่ผ่านก็ throw
  • Exhaustive check ด้วย never — บังคับจัดการครบทุกกรณี
  • กับดัก — narrowing หายใน callback แก้ด้วย const

บทถัดไป — Class


← บทที่ 06 | สารบัญ | บทที่ 08: Class →