Skip to content

บทที่ 10 — Advanced Patterns (Type-Level Programming)

← บทที่ 09 | สารบัญ | บทที่ 11: TS in Practice →

⏱ ใช้เวลา 3–4 ชั่วโมง — บทนี้ยากที่สุดในเล่ม


อ่านก่อนเริ่ม — ตั้งความคาดหวังให้ถูก

🚩 โซนขั้นสูง — ทั้งบทนี้ข้ามได้ บทนี้ยากที่สุดในเล่ม และ ไม่จำเป็นต่อการเขียน TypeScript ในงานประจำวัน ถ้าคุณเพิ่งเริ่ม ข้ามทั้งบทไปบทที่ 11 ได้เลย แล้วค่อยกลับมาเมื่อเจอสถานการณ์จริงที่ต้องใช้ — การข้ามบทนี้ไม่ทำให้คุณ "เขียน TypeScript ไม่เป็น" แต่อย่างใด

บทนี้คือ "type-level programming" (โปรแกรมระดับ type — เขียนตรรกะที่ทำงานในระดับ type system (ระบบ type) ไม่ใช่ที่ runtime) — การเขียนโปรแกรมในระดับ type ของ TypeScript

📚 คำศัพท์ที่จะเจอในบทนี้ (สรุปสั้น ๆ ไว้ก่อน — เจอจริงค่อยลงลึก)

  • conditional type (type แบบมีเงื่อนไข) — T extends U ? X : Y เลือก type ตามเงื่อนไข
  • mapped type (type ที่วน map) — "วน" ทุก key ของ type เดิมแล้วแปลง
  • distributive conditional (กระจายตัวบน union) — conditional type "แตก" union ออกทำทีละตัว
  • template literal type (แม่แบบข้อความ) — สร้าง string type จากแม่แบบ `Hello ${...}`
  • recursive type (type ที่อ้างถึงตัวเอง) — type ที่เรียกตัวเองได้ เช่น JSON
  • infer keyword (คำว่า infer = "อนุมาน/เดา") — บอก TypeScript ให้ "ดึง type ส่วนหนึ่งออกมา"
  • variance marker (out/in = ตัวกำกับทิศทาง) — บอกว่า generic เป็น covariant/contravariant
  • branded type (type ติดตรา) — เพิ่ม "ตรา" ลับให้ TypeScript แยก type ที่หน้าตาเหมือนกันออกได้

ถ้าอ่านแล้วยังไม่เข้าใจตอนนี้ ไม่เป็นไร — เอามาเทียบตอนเจอจริงในเนื้อหา

ขอบอกตรง ๆ ก่อน: เนื้อหาบทนี้ คุณไม่จำเป็นต้องใช้ทุกวัน แม้แต่มืออาชีพหลายคนก็เขียน type ระดับนี้ไม่บ่อย เทคนิคในบทนี้มีไว้สำหรับ:

  • คนเขียน library ที่อยากให้ type ของ library ฉลาดและปลอดภัยสุด ๆ
  • คนที่อยาก "อ่านโค้ด type ซับซ้อน" ของ library ดัง ๆ ออก
  • คนที่อยากเข้าใจ TypeScript ถึงแก่นจริง ๆ

ดังนั้น — ถ้าอ่านบทนี้แล้วงง ไม่ใช่ความผิดของคุณ คุณยังเขียน TypeScript ได้ดีโดยไม่ต้องเชี่ยวชาญทุกอย่างในบทนี้ แนะนำให้:

  1. อ่านรอบแรกแบบ "เข้าใจแนวคิด" ไม่ต้องจำทุกบรรทัด
  2. กลับมาอ่านลึกเมื่อเจอสถานการณ์จริงที่ต้องใช้

เอาล่ะ มาเริ่มกัน


Part 1: Mapped Types เจาะลึก

เราเจอ mapped type คร่าว ๆ ในบทที่ 06 แล้ว — "การวนทำกับทุก key ของ object type" บทนี้จะลงลึก

1.1 ทบทวน

ทบทวน "mapped type" — เทคนิคสร้าง type ใหม่โดย "วน" ทุก key ของ type เดิม ([K in keyof T]) แล้วแปลงแต่ละ field เหมือนการ map array แต่ทำในระดับ type:

typescript
type Stringify<T> = {
    [K in keyof T]: string;
    //  ↑ "สำหรับทุก key K ใน T" — ลูปในระดับ type
};

1.2 Modifier + และ - — เพิ่ม/ลบตัวกำกับ

ใน mapped type เราสามารถ "เพิ่ม" หรือ "ลบ" ตัวกำกับ readonly และ ? ได้ ด้วยเครื่องหมาย + / -

typescript
// ลบ ? ออก (ทำให้ทุก field บังคับ) — นี่คือวิธีที่ TypeScript สร้าง Required
type MyRequired<T> = {
    [K in keyof T]-?: T[K];
    //              ↑ -? = "ลบเครื่องหมาย ? ออก"
};

// ลบ readonly ออก (ทำให้แก้ได้)
type Mutable<T> = {
    -readonly [K in keyof T]: T[K];
    //  ↑ -readonly = "ลบ readonly ออก"
};

// เพิ่ม readonly (เหมือน Readonly ปกติ — + ใส่หรือไม่ใส่ก็ได้)
type MyReadonly<T> = {
    +readonly [K in keyof T]: T[K];
};

นี่คือเบื้องหลังของ utility type ที่เราใช้ — Required ใช้ -?, Readonly ใช้ +readonly ฯลฯ

1.3 Key Remapping (การเปลี่ยนชื่อ key) ด้วย as — เปลี่ยนชื่อ key

ความสามารถที่ทรงพลังของ mapped type — เราเปลี่ยน "ชื่อ" ของ key ได้ระหว่างวนลูป ด้วยคำว่า as:

typescript
type Getters<T> = {
    [K in keyof T as `get${Capitalize<K & string>}`]: () => T[K];
    //              ↑ as ... = เปลี่ยนชื่อ key ใหม่
    //                ↑ Capitalize<S> คือ built-in utility type ของ TypeScript (ไม่ต้อง import)
    //                  แปลงตัวอักษรแรกให้เป็นพิมพ์ใหญ่ เช่น Capitalize<"name"> = "Name"
};

type User = { name: string; age: number };
type UserGetters = Getters<User>;
// {
//     getName: () => string;
//     getAge: () => number;
// }

อธิบาย: ระหว่างวนแต่ละ key Kas \get${Capitalize<K & string>}`สั่งให้ "เปลี่ยนชื่อ key เป็น get + ตัวพิมพ์ใหญ่หน้า" ผลคือnamegetName`

เรื่อง K & string: ปกติ key ของ object ใน TypeScript ไม่ได้เป็น string เสมอไป — อาจเป็น number หรือ symbol ก็ได้ (ทบทวนจากบทที่ 05/06 เรื่อง keyof) แต่ template literal type (`get${...}`) ใช้ได้กับ string เท่านั้น เราจึงต้อง K & string เพื่อ "บังคับ" ให้ TypeScript มองว่า K เป็น string เสมอในจุดนี้ — เป็นการ narrow type ให้แคบลงพอจะใช้กับ template literal ได้

1.4 กรอง key ออก ด้วย as ... never

เคล็ดลับ: ถ้าระหว่าง remap เราเปลี่ยนชื่อ key เป็น never — key นั้นจะ "หายไป" จากผลลัพธ์ ใช้เทคนิคนี้ "กรอง" field:

typescript
// เก็บเฉพาะ field ที่ type เป็น string
type StringFieldsOnly<T> = {
    [K in keyof T as T[K] extends string ? K : never]: T[K];
    //              ↑ ถ้า field เป็น string → คงชื่อ K ไว้
    //                ถ้าไม่ใช่ → เปลี่ยนชื่อเป็น never → field หายไป
};

type Mixed = { name: string; age: number; email: string; active: boolean };
type OnlyStrings = StringFieldsOnly<Mixed>;
// { name: string; email: string }  ← เหลือแค่ field ที่เป็น string

Part 2: แม่แบบข้อความ — Template Literal Types

2.1 แนวคิด

จำ literal type จากบทที่ 02 ได้ไหม — type ที่เป็นค่าเป๊ะ ๆ เช่น "hello" Template literal type คือการสร้าง string literal type โดยใช้ "แม่แบบ" — เหมือน template string (`${...}`) แต่ในระดับ type:

typescript
type Greeting = `Hello, ${string}`;

const a: Greeting = "Hello, Alice";   // ✅ ตรงแม่แบบ
const b: Greeting = "Hello, World";   // ✅
const c: Greeting = "Hi, Alice";      // ❌ Error! ไม่ขึ้นต้นด้วย "Hello, "

`Hello, ${string}` หมายถึง "string ใด ๆ ที่ขึ้นต้นด้วย Hello, แล้วตามด้วยอะไรก็ได้"

2.2 พลังที่แท้จริง — รวมร่างกับ union

เมื่อใช้ template literal กับ union มันจะ "สร้างทุกการจับคู่" ให้:

typescript
type Color = "red" | "green" | "blue";
type Shade = "light" | "dark";

type ColorVariant = `${Shade}-${Color}`;
// "light-red" | "light-green" | "light-blue" |
// "dark-red" | "dark-green" | "dark-blue"
// — สร้างครบทั้ง 6 แบบให้อัตโนมัติ!

ตัวอย่างจริง — สร้าง type ของ CSS class:

typescript
type Direction = "top" | "right" | "bottom" | "left";
type MarginClass = `margin-${Direction}`;
// "margin-top" | "margin-right" | "margin-bottom" | "margin-left"

2.3 ใช้ร่วมกับ infer — แยกส่วนของ string

template literal type ใช้กับ infer (จากบทที่ 05) เพื่อ "แยกชิ้นส่วน" ของ string ได้:

typescript
// ดึง "method" ออกจาก string แบบ "GET /users"
type ExtractMethod<T> = T extends `${infer Method} ${string}` ? Method : never;

type A = ExtractMethod<"GET /users">;     // "GET"
type B = ExtractMethod<"POST /products">; // "POST"

`${infer Method} ${string}` แปลว่า "string ที่มีรูปแบบ (อะไรสักอย่าง)(ช่องว่าง)(อะไรสักอย่าง) — ให้ดึงส่วนแรกมาเรียก Method"


Part 3: Conditional Types เจาะลึก

เราเจอ conditional type (T extends U ? X : Y) ในบทที่ 05 แล้ว บทนี้ดูพฤติกรรมขั้นสูง

3.1 พฤติกรรมกระจายตัวบน union (Distributive)

นี่คือพฤติกรรมที่ทำให้มือใหม่งงมาก — เมื่อ conditional type เจอ generic ที่เป็น union มันจะ "แตก union ออกทำทีละตัว" แล้วรวมผลกลับ:

typescript
type ToArray<T> = T extends any ? T[] : never;

type Result = ToArray<string | number>;
// TypeScript ไม่ได้ทำกับ (string | number) ทั้งก้อน
// แต่แตกออก: ToArray<string> | ToArray<number>
//          = string[] | number[]

ผลลัพธ์เป็น string[] | number[] ไม่ใช่ (string | number)[] — เพราะการ "กระจายตัว"

ถ้าไม่อยากให้กระจายตัว — ครอบ generic ด้วย tuple [T]:

typescript
type ToArrayNoDistribute<T> = [T] extends [any] ? T[] : never;

type Result = ToArrayNoDistribute<string | number>;
// = (string | number)[]  ← ทำกับทั้งก้อน ไม่แตก

3.2 ใช้ Distributive กรอง union

พฤติกรรม distributive มีประโยชน์ — ใช้ "กรอง" สมาชิกของ union:

typescript
type KeepStrings<T> = T extends string ? T : never;

type Result = KeepStrings<"a" | 42 | "b" | true>;
// แตกทำทีละตัว:
//   "a" extends string ? → "a"
//   42  extends string ? → never (หายไป)
//   "b" extends string ? → "b"
//   true extends string? → never (หายไป)
// = "a" | "b"

(นี่คือเบื้องหลังของ Extract utility type)

3.3 Recursive Conditional Type — type ที่เรียกตัวเอง

conditional type เรียกตัวเองได้ ใช้แก้ปัญหาที่ "ลึกไม่จำกัดชั้น":

typescript
// แกะ array ที่ซ้อนกันออกจนได้ type ของสมาชิกข้างในสุด
type DeepFlattenArray<T> = T extends Array<infer Inner>
    ? DeepFlattenArray<Inner>   // ★ ถ้ายังเป็น array อยู่ → เรียกตัวเองอีก
    : T;                         // ถ้าไม่ใช่ array แล้ว → คืนค่า

type A = DeepFlattenArray<number[][][]>;   // number
type B = DeepFlattenArray<string[]>;       // string

Part 4: Branded Types (type ติดตรา) — แก้ปัญหา Structural Typing

เราสัญญาไว้ในบทที่ 04 ว่าจะอธิบายเรื่องนี้เต็ม ๆ — ถึงเวลาแล้ว

4.1 ทบทวนปัญหา

จำ structural typing จากบทที่ 00 ได้ไหม — TypeScript ดู "หน้าตา" ไม่ดู "ชื่อ" ปัญหาคือ:

typescript
type UserId = string;
type PostId = string;

function getUser(id: UserId) { /* ... */ }

const postId: PostId = "post-123";
getUser(postId);   // 😱 ไม่ error! เพราะ UserId กับ PostId ก็คือ string เหมือนกัน

เราเผลอเอา PostId ไปใช้กับฟังก์ชันที่ต้องการ UserId — TypeScript ไม่จับ เพราะหน้าตา (string) เหมือนกัน นี่เป็นบั๊กที่อันตรายมาก (ลองนึกภาพในระบบจริง — เอา PostId ไปเรียก getUser() ดึงผิดคน เผยข้อมูลผิด account!)

4.2 ทางออก — "ติดตรา" (Brand) ให้ type

แนวคิด: เราจะ "ปลอม" ให้ TypeScript คิดว่า UserId กับ PostId มีหน้าตาต่างกัน — โดยเพิ่ม property พิเศษที่ มีอยู่แค่ในระดับ type (ไม่มีจริงตอน runtime):

typescript
// แบบที่ 1 — literal-key brand (เห็นบ่อย เข้าใจง่าย)
type UserId = string & { readonly __brand: "UserId" };
type PostId = string & { readonly __brand: "PostId" };

string & { readonly __brand: "UserId" } แปลว่า "เป็น string และ มี property __brand ที่เป็น "UserId"" — ตอนนี้ UserId กับ PostId มี __brand ต่างกัน (literal "UserId" ≠ literal "PostId") TypeScript จึงแยกแยะได้ — getUser(somePostId) จะ error

⚠️ ข้อจำกัดของ literal-key brand: pattern string & {...} ไม่ใช่ "nominal brand เต็มตัว" — เช่น ส่งค่าที่มี brand (เช่น UserId) ไปให้พารามิเตอร์ที่รับแค่ string ธรรมดา ก็ยังทำได้อยู่ดี (ซึ่งมักเป็นพฤติกรรมที่เราต้องการอยู่แล้ว เพราะ UserId ก็คือ string ชนิดหนึ่ง) แต่ถ้าอยากได้การป้องกันที่แน่นกว่านี้ — กันไม่ให้ผสมกันได้เลยแม้แต่ทิศทางเดียว — ให้ใช้ unique symbol แบบที่ 2 ด้านล่าง

💡 แบบที่ 2 — unique symbol brand (แนะนำสำหรับ library/production)

ปัญหาของ __brand แบบ literal key: ถ้าสอง library บังเอิญตั้งชื่อ key __brand เหมือนกัน อาจ collide (collide = ชนกัน — สอง type ที่ตั้งใจให้คนละตัว กลับถูกมองเป็นตัวเดียวกัน) โดยไม่ตั้งใจ — unique symbol ทำให้ key เป็นของเรา 100% (ไม่มี library ไหนชนกันได้):

typescript
declare const userIdBrand: unique symbol;
declare const productIdBrand: unique symbol;

type UserId = string & { readonly [userIdBrand]: true };
type ProductId = string & { readonly [productIdBrand]: true };

ในบทเรียนนี้เราใช้แบบ literal key เพื่อให้อ่านง่าย — แต่ถ้าคุณเขียน library ที่จะ publish ขึ้น npm หรืออยู่ใน monorepo ใหญ่ ให้ใช้ unique symbol แทน

4.3 ใช้งาน

เนื่องจาก __brand ไม่มีอยู่จริง เราสร้าง branded value ด้วย as ผ่านฟังก์ชันที่ตรวจสอบ:

typescript
type UserId = string & { readonly __brand: "UserId" };

// ฟังก์ชันสร้าง UserId — ตรวจสอบก่อน แล้วค่อย "ติดตรา"
function makeUserId(value: string): UserId {
    if (!value.startsWith("user-")) {
        throw new Error("รูปแบบ UserId ไม่ถูกต้อง");
    }
    return value as UserId;   // ติดตรา
}

function getUser(id: UserId) { /* ... */ }

const id = makeUserId("user-123");
getUser(id);              // ✅
getUser("user-123");      // ❌ Error! string ธรรมดาไม่ใช่ UserId (ไม่มีตรา)

ตอนนี้ — string ธรรมดาส่งให้ getUser ไม่ได้แล้ว ต้องผ่าน makeUserId ที่ตรวจสอบเท่านั้น ปลอดภัยขึ้นมาก

💡 ข้อสำคัญ: __brand ไม่มีอยู่จริงตอน runtime — มันเป็นแค่ "ตราในจินตนาการ" ที่ TypeScript ใช้แยกแยะ ตอน runtime UserId ก็คือ string ธรรมดา ไม่มี overhead อะไร


Part 5: Recursive Types — type ที่อ้างถึงตัวเอง

บางโครงสร้างข้อมูล "ซ้อนตัวเองได้ไม่จำกัด" — เช่น JSON ตัวอย่างคลาสสิก: type ที่อธิบาย "ค่า JSON ใด ๆ":

typescript
type JsonValue =
    | string
    | number
    | boolean
    | null
    | JsonValue[]                    // ★ array ของ JsonValue (อ้างถึงตัวเอง)
    | { [key: string]: JsonValue };  // ★ object ที่ value เป็น JsonValue (อ้างถึงตัวเอง)

const data: JsonValue = {
    name: "Alice",
    age: 30,
    active: true,
    tags: ["admin", "user"],
    address: {
        city: "Bangkok",
        coordinates: [13.7, 100.5],
    },
};

type JsonValue อ้างถึงตัวเอง — ทำให้มันอธิบายโครงสร้างที่ซ้อนลึกเท่าไรก็ได้

⚠️ ข้อจำกัด (อ่านผ่าน ๆ ก็พอ): TypeScript มี เพดานความลึก ที่ตั้งไว้ในตัว compiler — ถ้า recursive ลึกเกินไป (เช่น JSON ที่ซ้อนหลายสิบชั้น หรือ type-level program ที่คำนวณซ้ำมาก) จะแจ้ง error Type instantiation is excessively deep and possibly infinite แปลว่า "type ลึกเกินไป อาจไม่จบ" ในทางปฏิบัติเจอเฉพาะตอนเขียน type-level program จัด ๆ — JSON ปกติของ API ไม่เจอ

สำหรับคนที่อยากรู้เพิ่ม — เรื่อง compiler internals

เพดานความลึกมีหลายตัว เช่น instantiation depth (ความลึกของการสร้าง instance type), type comparison depth (ความลึกของการเทียบ type), และ tail-call optimization (TCO — เทคนิคช่วยให้ฟังก์ชัน/type ที่เรียกตัวเองซ้ำ ๆ ไม่ต้องกินหน่วยความจำเพิ่มทุกครั้งที่เรียก) สำหรับ recursive conditional type ตัวเลขเป๊ะ ๆ จะเปลี่ยนตามเวอร์ชัน TS — ดู release notes หรือซอร์สโค้ดของ compiler ถ้าต้องการความแม่นยำ


Part 6: Tuple Manipulation — จัดการ tuple ระดับ type

เราจัดการ tuple ในระดับ type ได้ ด้วย infer และ spread ...:

typescript
// ดึงสมาชิกตัวแรก
type Head<T extends any[]> = T extends [infer First, ...any[]] ? First : never;

// ดึงสมาชิกตัวสุดท้าย
type Tail<T extends any[]> = T extends [...any[], infer Last] ? Last : never;

// นับจำนวนสมาชิก
type Count<T extends any[]> = T["length"];

// เพิ่มสมาชิกท้าย
type Append<T extends any[], V> = [...T, V];

type A = Head<[1, 2, 3]>;        // 1
type B = Tail<[1, 2, 3]>;        // 3
type C = Count<[1, 2, 3]>;       // 3
type D = Append<[1, 2], 3>;      // [1, 2, 3]

เทคนิคพวกนี้ใช้ใน library ขั้นสูง (เช่นการทำ type ของฟังก์ชัน curry — เทคนิคแปลงฟังก์ชันหลายพารามิเตอร์ให้เป็นฟังก์ชันทีละตัว) — งานทั่วไปไม่ค่อยได้ใช้ตรง ๆ

⚠️ หมายเหตุ: ตัวอย่าง Append ข้างบนได้ผลเป๊ะเพราะ T ที่ส่งเข้าไปเป็น tuple literal (เช่น [1, 2] ที่รู้จำนวนและลำดับสมาชิกแน่นอน) ถ้า T เป็น array กว้าง ๆ แทน (เช่น number[] ที่ไม่รู้ความยาวแน่นอน) ผลลัพธ์จะไม่ได้ fixed-length tuple ตามที่หวัง — จะกลายเป็น [...number[], V] แทน pattern นี้จึงใช้ได้แม่นยำเฉพาะตอน T เป็น tuple literal จริง ๆ

💡 หมายเหตุเรื่อง any[]: ตัวอย่างข้างบนใช้ T extends any[] ตามแบบที่เห็นบ่อยในซอร์สของ library — แต่ในโปรเจกต์ที่เปิด lint เข้ม any[] อาจถูกฟ้อง unknown[] เข้มงวดกว่าและปลอดภัยกว่า แต่การสลับไปใช้ไม่ใช่แค่เปลี่ยนคำเฉย ๆ — ต้องปรับโค้ดในบางจุดตามมาด้วย (เช่นค่าที่ดึงออกมาจะมี type เป็น unknown แทน any ทำให้ต้อง narrow ก่อนใช้งานจริง) บางสไตล์ไกด์ก็แนะนำ readonly unknown[] ด้วย


Part 7: as const satisfies — รวมพลังสองคีย์เวิร์ด

จำ as const (บทที่ 02) และ satisfies (บทที่ 04) ได้ไหม — เราใช้ทั้งคู่พร้อมกันได้ เพื่อให้ได้ผลดีที่สุด:

typescript
type RouteConfig = Record<string, string>;

const routes = {
    home: "/",
    profile: "/profile",
    settings: "/settings",
} as const satisfies RouteConfig;
//  ↑ as const = แช่แข็งให้เป็น literal เป๊ะ ๆ + readonly
//             ↑ satisfies = ตรวจว่าตรงกับ RouteConfig

// ผลลัพธ์ — ได้ทั้งสองอย่าง:
type RouteName = keyof typeof routes;
// "home" | "profile" | "settings"  ← เป๊ะ เพราะ as const

routes.home;   // type: "/"  ← เป๊ะ เพราะ as const
// และถ้า value ไม่ตรง RouteConfig → satisfies จะฟ้อง

as const satisfies คือ pattern ที่ดีที่สุดสำหรับ "object คงที่ที่อยากได้ทั้งความเป๊ะและการตรวจสอบ" — เช่น config, ตาราง lookup, ชุดค่าคงที่


Part 8: เรื่องประสิทธิภาพ (Performance)

type ที่ซับซ้อนเกินไปทำให้ TypeScript ทำงานช้า (autocomplete หน่วง, compile นาน) ข้อควรระวัง:

  • อย่า recursive ลึกเกินจำเป็น — type ที่เรียกตัวเองหลายสิบชั้นทำให้ช้า
  • skipLibCheck: true (เราเปิดไว้แล้วในบทที่ 01) — ข้ามการตรวจ type ใน library ช่วยให้เร็วขึ้น
  • อย่าใช้ type-level computation เกินงาม — เทคนิคในบทนี้เท่ ๆ แต่ถ้าใช้พร่ำเพรื่อ โค้ดจะอ่านยากและ IDE ช้า

🎯 หลักคิด: type ที่ดีคือ type ที่ "ช่วยคนอ่านเข้าใจ" ไม่ใช่ type ที่ "โชว์ว่าเราเก่ง" — ถ้า type ซับซ้อนจนเพื่อนร่วมทีมอ่านไม่ออก มันอาจซับซ้อนเกินไป


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

Lab 1 — Branded type

ฝึกเทคนิค "branded type" — ทำให้ string ธรรมดากับ Email เป็นคนละ type (แม้ค่าจริงเป็น string) โดยแปะ marker ลับ ป้องกันการส่ง string ที่ยังไม่ validate เข้าฟังก์ชันที่ต้องการ Email:

typescript
type Email = string & { readonly __brand: "Email" };

function makeEmail(value: string): Email {
    if (!/^[^@]+@[^@]+\.[^@]+$/.test(value)) {
        throw new Error("รูปแบบอีเมลไม่ถูกต้อง");
    }
    return value as Email;
}

function sendNewsletter(to: Email): void {
    console.log(`ส่งจดหมายถึง ${to}`);
}

const email = makeEmail("alice@example.com");
sendNewsletter(email);                  // ✅
sendNewsletter("not-an-email");          // ❌ Error! ไม่ใช่ Email

Lab 2 — Mapped type กรอง field

ฝึกใช้ mapped type + key remapping (as ... extends ... ? K : never) เพื่อกรองเอาเฉพาะ field บางชนิด (เช่น field ที่เป็น method) ออกมาเป็น type ใหม่ — เห็นพลังของ type-level programming:

typescript
// สร้าง type ที่เก็บเฉพาะ field ที่เป็นฟังก์ชัน
// หมายเหตุ: ใช้ (...args: any[]) => any แทน Function เพราะ kฎ default ของ typescript-eslint
// (linter — เครื่องมือตรวจคุณภาพโค้ด) ชื่อ rule @typescript-eslint/no-unsafe-function-type
// ห้ามใช้ bare Function (Function ตัวเปล่า ไม่มีลายเซ็น) — มันกว้างเกินไป
// (รวมถึง class constructors และของแปลก ๆ อื่น)
type MethodsOnly<T> = {
    [K in keyof T as T[K] extends (...args: any[]) => any ? K : never]: T[K];
};

type Widget = {
    name: string;
    width: number;
    render: () => void;
    destroy: () => void;
};

type WidgetMethods = MethodsOnly<Widget>;
// { render: () => void; destroy: () => void }

Lab 3 — Template literal type

typescript
type HttpMethod = "GET" | "POST" | "PUT" | "DELETE";
type Resource = "users" | "products";

type ApiRoute = `${HttpMethod} /${Resource}`;
// "GET /users" | "GET /products" | "POST /users" | ... (8 แบบ)

function callApi(route: ApiRoute): void {
    console.log(`เรียก ${route}`);
}

callApi("GET /users");        // ✅
callApi("PATCH /users");      // ❌ Error! ไม่มี method PATCH
callApi("GET /orders");       // ❌ Error! ไม่มี resource orders

Part 10: Checkpoint

1. Modifier + และ - ใน mapped type ทำอะไร?

ใช้เพิ่ม/ลบตัวกำกับ readonly และ ? เช่น -? ลบ optional ออก (ทำให้บังคับ), -readonly ลบ readonly ออก (ทำให้แก้ได้) — เป็นเบื้องหลังของ Required, Mutable

2. Key remapping ด้วย as ทำอะไร?

เปลี่ยนชื่อ key ระหว่างวน mapped type — และถ้า remap เป็น never key นั้นจะหายไป ใช้กรอง field ได้

3. Template literal type คืออะไร?

การสร้าง string literal type จาก "แม่แบบ" เช่น `Hello, ${string}` — เมื่อใช้กับ union จะสร้างทุกการจับคู่ให้อัตโนมัติ

4. พฤติกรรม distributive ของ conditional type คืออะไร?

เมื่อ conditional type เจอ generic ที่เป็น union มันจะแตก union ออกทำทีละตัวแล้วรวมผล — ป้องกันได้ด้วยการครอบ [T]

5. Branded type แก้ปัญหาอะไร?

แก้ปัญหา structural typing — ทำให้ type ที่หน้าตาเหมือนกัน (เช่น UserId กับ ProductId ที่เป็น string ทั้งคู่) ถูก TypeScript แยกแยะได้ โดยเพิ่ม property __brand ที่มีแค่ระดับ type

6. Recursive type มีข้อจำกัดอะไร?

TypeScript มีขีดจำกัดความลึก (เลขเป๊ะ ๆ ขึ้นกับเวอร์ชัน — ในการคำนวณซับซ้อนมักอยู่ราวหลักสิบชั้น) — ถ้า recursive ลึกเกินไปจะ error งานทั่วไปแทบไม่เจอ

7. as const satisfies ใช้ตอนไหน?

ใช้กับ object คงที่ที่ต้องการทั้ง "ความเป๊ะของ literal type" (จาก as const) และ "การตรวจสอบว่าตรง type" (จาก satisfies) — เหมาะกับ config, lookup table


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

  • Mapped type+/- ปรับ modifier, as เปลี่ยน/กรอง key
  • Template literal type — สร้าง string type จากแม่แบบ, รวมกับ union ได้ทุกการจับคู่
  • Conditional type — distributive (กระจายบน union), recursive (เรียกตัวเอง)
  • Branded type — แก้ปัญหา structural typing แยกแยะ type ที่หน้าตาเหมือนกัน
  • Recursive type — อธิบายโครงสร้างซ้อนได้ (เช่น JSON) มีขีดจำกัดความลึก
  • as const satisfies — pattern ที่ดีที่สุดสำหรับ object คงที่
  • ⚠️ type ที่ดี = type ที่ช่วยคนอ่านเข้าใจ ไม่ใช่ type ที่ซับซ้อนเพื่อโชว์

บทถัดไป (บทสุดท้าย) — เอา TypeScript ไปใช้งานจริง


← บทที่ 09 | สารบัญ | บทที่ 11: TS in Practice →


🔤 Glossary · 📋 Style guide · 📅 last_verified: 2026-06-03