Skip to content

บทที่ 05 — Generics (เจาะลึก)

← บทที่ 04 | สารบัญ | บทที่ 06: Utility Types →

⏱ ใช้เวลา 3–4 ชั่วโมง — บทนี้ยากกว่าบทก่อน ๆ ค่อย ๆ อ่าน อย่าใจร้อน

🟡 หมายเหตุระดับ: บทนี้ติดป้าย "กลาง" แต่ Part 9.4–9.6 (const type parameter, NoInfer, variance) จัดเป็น "ขั้นสูง — สำหรับคนเขียน library" ไม่ใช่เนื้อหามือใหม่

  • ของใหม่ใน TS 5.x (const ต้อง TS 5.0+, NoInfer ต้อง TS 5.4+)
  • library = ชุดโค้ดสำเร็จรูปที่คนอื่นเอาไปใช้ (เช่น React, Zod) — ปกติคนใช้งานทั่วไปไม่ต้องเขียนเอง
  • variance = "ทิศทางการเข้ากันได้ของ subtype" (เรื่อง type ตัวลูก-ตัวพ่อ) — เป็นทฤษฎีเชิงลึกของ type system

รอบแรก ข้าม Part 9.4–9.6 ไปได้เลย ค่อยกลับมาเมื่อต้องเขียน library ของตัวเอง หรือเจอ error แปลก ๆ ที่ต้องเข้าใจกลไกของ TypeScript ลึก ๆ


บทนี้คือจุดเปลี่ยน

Generics คือเส้นแบ่งระหว่าง "คนที่ใช้ TypeScript เป็น" กับ "คนที่เพิ่งเริ่ม" มันคือหัวข้อที่มือใหม่กลัวมากที่สุด เพราะหน้าตาแปลก (<T> เต็มไปหมด)

แต่ความจริงแล้ว แนวคิดของ generics นั้น ง่ายมาก — บทนี้จะค่อย ๆ พาคุณไปทีละขั้น เริ่มจากปัญหาที่เข้าใจง่าย แล้วค่อยไต่ขึ้นไปจนถึงเทคนิคระดับสูงที่ library ดัง ๆ ใช้กัน

ถ้าผ่านบทนี้ได้ คุณจะอ่านโค้ด TypeScript ของมืออาชีพออกแทบทั้งหมด


Part 1: ปัญหา — ทำไมต้องมี Generic

1.1 มาดูปัญหาก่อน

สมมติเราต้องเขียนฟังก์ชันง่าย ๆ — "เอาสมาชิกตัวแรกของ array ออกมา"

ถ้าเป็น array ของ string:

typescript
function firstString(arr: string[]): string {
    return arr[0];
}

แล้วถ้าเป็น array ของ number ล่ะ? เราต้องเขียนอีกฟังก์ชัน:

typescript
function firstNumber(arr: number[]): number {
    return arr[0];
}

แล้ว array ของ boolean? ของ User? ของ Product? — เราต้องเขียนฟังก์ชันใหม่ทุกครั้ง ทั้งที่ โค้ดข้างในเหมือนกันเป๊ะ ต่างกันแค่ type นี่คือการเขียนโค้ดซ้ำที่น่าเบื่อและบำรุงรักษายาก

1.2 ทางแก้ที่ผิด — ใช้ any

มือใหม่อาจคิดว่า "ก็ใช้ any สิ จบ":

typescript
function first(arr: any[]): any {
    return arr[0];
}

ฟังก์ชันนี้รับ array อะไรก็ได้จริง — แต่จำบทที่ 02 ได้ไหม any คือการปิดสวิตช์ TypeScript ปัญหาคือ:

typescript
const name = first(["Alice", "Bob"]);
name.toFixed(2);   // 😱 ไม่ error! ทั้งที่ name เป็น string ที่ไม่มี toFixed

เราเสีย type ไปหมด — first คืน any TypeScript เลยปกป้องอะไรไม่ได้เลย เราไม่ต้องการแบบนี้

1.3 สิ่งที่เราต้องการจริง ๆ

เราอยากได้ฟังก์ชัน first ที่:

  • เขียนครั้งเดียว ใช้กับ array ทุกชนิด
  • และยังรู้ type — ส่ง string[] เข้าไป ต้องได้ string กลับมา, ส่ง number[] ต้องได้ number

ฟังดูเหมือนขอมากเกินไป? — นี่แหละคือสิ่งที่ generic ทำได้


Part 2: แนวคิดของ Generic — "พารามิเตอร์ แต่สำหรับ type"

2.1 เปรียบเทียบกับสิ่งที่คุณรู้อยู่แล้ว

คุณรู้จัก พารามิเตอร์ของฟังก์ชัน อยู่แล้ว — มันคือ "ช่องว่างสำหรับ ค่า" ที่เราจะเติมตอนเรียกใช้:

typescript
function greet(name: string) {   // name = ช่องว่างสำหรับ "ค่า"
    return `สวัสดี ${name}`;
}
greet("Alice");   // เติมค่า "Alice" ลงในช่องว่าง

Generic ก็คือแบบเดียวกัน — แต่เป็น "ช่องว่างสำหรับ type"

typescript
function first<T>(arr: T[]): T | undefined {
    //         ↑ T = ช่องว่างสำหรับ "type"
    return arr[0];
    // คืน T | undefined เพราะ arr อาจว่าง (noUncheckedIndexedAccess)
}

<T> ที่อยู่หลังชื่อฟังก์ชัน คือการประกาศว่า "ฟังก์ชันนี้มีช่องว่างสำหรับ type ชื่อ T" — เมื่อมีคนเรียกใช้ฟังก์ชัน TypeScript จะ "เติม" type จริงลงในช่อง T

2.2 มาดูว่ามันทำงานยังไง

typescript
function first<T>(arr: T[]): T | undefined {
    return arr[0];
}

const a = first(["Alice", "Bob"]);   // เราส่ง string[] → T กลายเป็น string → a เป็น string | undefined
const b = first([1, 2, 3]);          // เราส่ง number[]  → T กลายเป็น number → b เป็น number | undefined
const c = first([true, false]);      // เราส่ง boolean[] → T กลายเป็น boolean → c เป็น boolean | undefined

เกิดอะไรขึ้น? เมื่อเราเรียก first(["Alice", "Bob"]):

  1. TypeScript เห็นว่าเราส่ง string[] เข้าไป
  2. ฟังก์ชันบอกว่าพารามิเตอร์เป็น T[] — ดังนั้น T ต้องเป็น string
  3. return type คือ T | undefined ซึ่ง T ตอนนี้ = stringa จึงเป็น string | undefined

💡 ทำไมต้อง T | undefined? เมื่อเปิด noUncheckedIndexedAccess: true (แนะนำใน tsconfig) TypeScript จะรู้ว่า arr[0] อาจ undefined ได้ถ้า array ว่าง — ดังนั้น return type ที่ถูกต้องคือ T | undefined ไม่ใช่แค่ T (สังเกตว่า Lab 1 ของบทนี้ก็ใช้ pattern นี้เช่นกัน)

⚠️ ถ้าลองเองแล้วเห็นแค่ T ไม่ใช่ T | undefined: แปลว่า tsconfig ของคุณยังไม่ได้เปิด noUncheckedIndexedAccess (ตัวเลือกนี้อยู่ในกลุ่ม "ระดับแนะนำเพิ่ม" ของบทที่ 01 ซึ่งเป็นตัวเลือกเสริม ไม่ได้เปิดอัตโนมัติ) ไม่ต้องตกใจ — ผลลัพธ์ทั้งสองแบบถูกต้อง แค่ระดับความเข้มงวดต่างกัน

ผลลัพธ์: เขียนฟังก์ชันครั้งเดียว ใช้ได้ทุกชนิด และยังรู้ type เป๊ะ ๆ:

typescript
const name = first(["Alice", "Bob"]);
// name เป็น string | undefined (อาจว่างถ้า array ว่าง)
if (name !== undefined) {
    name.toUpperCase();   // ✅ TypeScript รู้ว่า name เป็น string หลัง narrowing
    name.toFixed(2);      // ❌ Error! string ไม่มี toFixed — TypeScript ยังปกป้องเราอยู่
}

นี่คือความมหัศจรรย์ของ generic — เอาความยืดหยุ่นของ any มา โดยไม่เสีย type safety

2.3 ทำความเข้าใจตัวอักษร T

T เป็นแค่ "ชื่อ" ของช่องว่าง เหมือนเราตั้งชื่อพารามิเตอร์ว่า name คุณจะตั้งชื่ออะไรก็ได้:

typescript
function first<Item>(arr: Item[]): Item {
    return arr[0];
}

แต่ตามธรรมเนียม คนนิยมใช้ตัวอักษรตัวเดียวพิมพ์ใหญ่:

  • T — ย่อจาก "Type" (ใช้บ่อยที่สุด)
  • K — ย่อจาก "Key"
  • V — ย่อจาก "Value"
  • E — ย่อจาก "Element"
  • U, R — ตัวที่ 2, 3 เมื่อมีหลายตัว

💡 มือใหม่: ไม่ต้องท่องตารางนี้ — สำหรับฟังก์ชัน generic ธรรมดา ใช้ T ตัวเดียวก็พอ ตัวอักษรอื่นเป็นแค่ธรรมเนียมที่จะค่อย ๆ คุ้นเมื่ออ่านโค้ดของคนอื่น

ใช้ชื่อสื่อความหมายก็ได้ถ้าช่วยให้อ่านง่ายขึ้น — ในโค้ดซับซ้อนการตั้งชื่อยาว ๆ อย่าง TData, TError ช่วยได้มาก

2.4 การระบุ type เอง (ปกติไม่ต้อง)

ปกติ TypeScript "เดา" type ของ T ให้เองจากค่าที่เราส่ง (เรียกว่า inference) แต่เราระบุเองได้ด้วยการใส่ <...> ตอนเรียก:

typescript
const x = first<string>(["a", "b"]);   // ระบุชัดว่า T = string

ส่วนใหญ่ไม่ต้องระบุ — ปล่อยให้ TypeScript เดา ระบุเองเฉพาะตอนที่มันเดาไม่ได้ หรือเดาผิดจากที่เราต้องการ


Part 3: Generic Function — ลงรายละเอียด

3.1 ฟังก์ชัน generic ที่ง่ายที่สุด

ฟังก์ชัน generic ที่คลาสสิกที่สุดคือ identity — รับค่าอะไรมา ก็คืนค่านั้นกลับไป:

typescript
function identity<T>(value: T): T {
    return value;
}

const n = identity(42);        // T = number,  n เป็น number
const s = identity("hello");   // T = string,  s เป็น string

3.2 Generic หลายตัว

ฟังก์ชันมีช่องว่าง type ได้หลายช่อง — แค่ใส่หลายตัวคั่นด้วย ,:

typescript
function makePair<A, B>(first: A, second: B): [A, B] {
    return [first, second];
}

const p = makePair("อายุ", 25);   // A = string, B = number → p เป็น [string, number]

3.3 ค่าเริ่มต้นของ generic

เหมือนพารามิเตอร์ปกติที่มีค่า default ได้ generic ก็มี type เริ่มต้นได้ ด้วย =:

typescript
function createArray<T = string>(): T[] {
    return [];
}

const a = createArray();          // ไม่ระบุ → ใช้ค่าเริ่มต้น → T = string → a เป็น string[]
const b = createArray<number>();  // ระบุ → T = number → b เป็น number[]

3.4 ข้อควรระวังในไฟล์ .tsx (React)

📌 JSX = ไวยากรณ์ของ React — ใช้เขียน UI (หน้าจอ) แบบที่หน้าตาคล้าย HTML ปนใน JavaScript เช่น <button>คลิก</button> ส่วน .tsx คือไฟล์ TypeScript ที่อนุญาตให้เขียน JSX ได้

ถ้ายังไม่ได้เรียน React ข้ามหัวข้อย่อยนี้ไปก่อนได้ — เรียนรู้แยกในคอร์ส React โดยเฉพาะ จดไว้ว่า "ถ้าวันหนึ่งเขียน React แล้วเจอเคสนี้ ค่อยกลับมาดู"

ถ้าคุณเขียน arrow function แบบ generic ในไฟล์ .tsx (ไฟล์ React) จะมีปัญหา:

tsx
const identity = <T>(x: T): T => x;
//               ↑ ใน .tsx TypeScript นึกว่า <T> เป็น JSX tag (แท็ก UI) → error

วิธีแก้ — ใส่ , หลัง T:

tsx
const identity = <T,>(x: T): T => x;   // ✅ ใส่ comma บอกว่าไม่ใช่ JSX

อีกทางเลือก — เขียน <T extends unknown> แทน (linter บางตัวชอบทางนี้มากกว่า):

tsx
const identity = <T extends unknown>(x: T): T => x;   // ✅ ใช้ได้ใน .tsx เช่นกัน

จำไว้: เจอ <T,> หรือ <T extends unknown> แปลก ๆ ในโค้ด React — นั่นคือเหตุผล


Part 4: Generic ใน Interface, Type, และ Class

Generic ไม่ได้ใช้กับฟังก์ชันอย่างเดียว — ใช้กับ type, interface, class ได้ด้วย

4.1 Generic Interface / Type

typescript
// interface แบบ generic — "กล่อง" ที่ใส่ของชนิดอะไรก็ได้
interface Box<T> {
    value: T;
    label: string;
}

const numberBox: Box<number> = { value: 42, label: "เลข" };
const textBox: Box<string> = { value: "สวัสดี", label: "ข้อความ" };

สังเกตว่าตอนใช้ เราต้อง "เติม" type ลงในช่อง — Box<number>, Box<string>

ใช้กับ type ก็ได้:

typescript
type ApiResponse<T> = {
    data: T;
    status: number;
    timestamp: number;
};

type UserResponse = ApiResponse<{ name: string }>;
// = { data: { name: string }; status: number; timestamp: number }

ตัวอย่างจริงที่คุณเจอบ่อย — Array<T>, Promise<T> ล้วนเป็น generic type ที่ TypeScript เตรียมไว้ให้:

  • Array<string> = array ของ string (เขียนแบบ string[] ก็ได้)
  • Promise<User> = Promise ที่จะคืน User

4.2 Generic Class

typescript
class Stack<T> {
    private items: T[] = [];

    push(item: T): void {
        this.items.push(item);
    }

    pop(): T | undefined {
        return this.items.pop();
    }

    peek(): T | undefined {
        return this.items[this.items.length - 1];
    }
}

const numberStack = new Stack<number>();
numberStack.push(1);
numberStack.push(2);
numberStack.push("3");   // ❌ Error! stack นี้รับแค่ number

const stringStack = new Stack<string>();
stringStack.push("a");   // ✅

Stack<T> คือ "โครงของ stack" ที่ทำงานกับ type อะไรก็ได้ พอ new Stack<number>() เราก็ได้ stack เฉพาะของ number ที่ปลอดภัย

(เรื่อง class แบบเต็ม ๆ อยู่ในบทที่ 08)


Part 5: Constraint — จำกัดขอบเขตของ Generic

📌 Constraint = "ข้อจำกัด / เงื่อนไขขั้นต่ำ" — บอก TypeScript ว่า T ไม่ใช่ "อะไรก็ได้จริง ๆ" แต่ต้องผ่านเงื่อนไขที่เรากำหนด

5.1 ปัญหา — generic ที่ "อิสระเกินไป"

generic <T> ธรรมดา หมายถึง "type อะไรก็ได้" — แต่บางครั้งมันอิสระเกินไปจนใช้ไม่ได้:

typescript
function logLength<T>(value: T): void {
    console.log(value.length);
    //                ↑ ❌ Error! Property 'length' does not exist on type 'T'
}

ทำไม error? เพราะ T คือ "อะไรก็ได้" — และ "อะไรก็ได้" บางอย่างก็ไม่มี .length (เช่น number, boolean) TypeScript จึงไม่ยอมให้เราใช้ .length

5.2 ทางแก้ — Constraint ด้วย extends

เราต้องบอก TypeScript ว่า "T ไม่ใช่อะไรก็ได้นะ — มันต้องเป็นของที่ มี .length เป็นอย่างน้อย" ทำได้ด้วยคำว่า extends:

typescript
function logLength<T extends { length: number }>(value: T): void {
    //              ↑ T ต้องเป็นอะไรก็ได้ "ที่มี property length เป็น number"
    console.log(value.length);   // ✅ ตอนนี้ TypeScript มั่นใจว่า value มี length
}

logLength("hello");          // ✅ string มี length
logLength([1, 2, 3]);        // ✅ array มี length
logLength({ length: 10 });   // ✅ object นี้มี length
logLength(42);               // ❌ Error! number ไม่มี length

<T extends X> แปลว่า "T เป็นอะไรก็ได้ ตราบใดที่มันเข้ากันได้กับ X" — เป็นการ "ตั้งเงื่อนไขขั้นต่ำ" ให้ generic

💡 อย่าสับสน: คำว่า extends ในที่นี้ ไม่เหมือน extends ของ class (การสืบทอด) ในบริบทของ generic extends หมายถึง "ต้องเข้ากันได้กับ / เป็นชนิดย่อยของ" — มันคือการ "ตั้งเงื่อนไข"

5.3 Constraint ที่ทรงพลังที่สุด — keyof

ส่วนนี้สำคัญมากและมือใหม่มักงง ตั้งใจอ่าน

keyof คืออะไร?keyof เป็นตัวดำเนินการที่ "ดึงรายชื่อ key ทั้งหมดของ object type ออกมาเป็น union ของ literal":

typescript
type User = {
    name: string;
    age: number;
    email: string;
};

type UserKeys = keyof User;
// UserKeys = "name" | "age" | "email"

keyof User ให้ผลเป็น "name" | "age" | "email" — union ของชื่อ key ทั้งหมด

T[K] คืออะไร? — เมื่อมี object type T และ key K เราเขียน T[K] เพื่อ "ดึง type ของ property ตัวนั้น":

typescript
type User = { name: string; age: number };

type NameType = User["name"];   // string
type AgeType = User["age"];     // number

User["name"] = type ของ property name = string — เรียกว่า "indexed access type"

เอามารวมกัน — ฟังก์ชันอ่าน property ที่ปลอดภัย:

ปัญหา: เราอยากเขียนฟังก์ชัน getProperty(obj, key) ที่ดึง property ออกจาก object — แต่อยากให้:

  1. ใส่ key ผิด (key ที่ไม่มีจริง) → error
  2. type ที่คืนถูกต้องตาม property ที่ดึง
typescript
function getProperty<T, K extends keyof T>(obj: T, key: K): T[K] {
    //               ↑ T = type ของ object
    //                  ↑ K = ต้องเป็น key ของ T เท่านั้น (constraint!)
    //                                            ↑ คืน type ของ property นั้น
    return obj[key];
}

const user = { name: "Alice", age: 30 };

const name = getProperty(user, "name");   // type: string ✅
const age = getProperty(user, "age");     // type: number ✅
const x = getProperty(user, "email");     // ❌ Error! "email" ไม่ใช่ key ของ user

ทำความเข้าใจทีละส่วน:

  • K extends keyof T — บังคับว่า key ที่ส่งมาต้องเป็น key ที่มีจริงใน obj เท่านั้น (ถ้าส่ง "email" ที่ไม่มี → error)
  • return type T[K] — คืน type ของ property ที่ดึงมาเป๊ะ ๆ (ดึง "name" ได้ string, ดึง "age" ได้ number)

นี่คือ pattern ที่ใช้บ่อยมากในงานจริง และเป็นรากฐานของเทคนิคขั้นสูงในบทที่ 06 และ 10


Part 6: Type Inference ของ Generic

เราพูดถึงไปบ้างแล้วว่า TypeScript "เดา" type ของ generic ให้เอง มาดูให้ลึกขึ้น

6.1 TypeScript เดาจาก argument

typescript
function wrap<T>(value: T): { value: T } {
    return { value };
}

const a = wrap(42);          // TypeScript เห็น 42 → เดา T = number
const b = wrap("hello");     // เห็น "hello" → เดา T = string
const c = wrap([1, 2, 3]);   // เห็น number[] → เดา T = number[]

เกือบทุกครั้งเราไม่ต้องระบุ <T> เอง — ปล่อยให้ TypeScript ทำงาน

6.2 ระวังเรื่อง "type widening" (การขยาย type ให้กว้างขึ้น)

📌 widening = "การขยายให้กว้าง" — TypeScript มักเดา type แบบ "กว้างไว้ก่อน" เช่นเห็นค่า "hello" ก็เดาว่าเป็น string ทั่วไป ไม่ใช่ literal "hello" เป๊ะ ๆ

มีรายละเอียดที่ควรรู้ — เวลา TypeScript เดา type จากค่า มันมักเดา "กว้าง":

typescript
const a = wrap("hello");
// a เป็น { value: string }  ← เดากว้างเป็น string

const b = wrap("hello" as const);
// b เป็น { value: "hello" }  ← as const ทำให้เดาแคบเป็น literal

ถ้าอยากได้ literal type เป๊ะ ๆ ใช้ as const ช่วย (จำจากบทที่ 02 ได้ไหม)


Part 7: Conditional Type — type ที่ "ตัดสินใจ" ได้

📌 Conditional Type = "ชนิดที่มีเงื่อนไข" — type ที่ตัดสินใจได้ว่า "ถ้าเป็นแบบ A → ใช้แบบ X ไม่งั้น → ใช้แบบ Y" เหมือน if-else แต่อยู่ในระดับ type

🚩 โซนขั้นสูง — ข้ามได้ (Part 7-8) จากตรงนี้ไปคือเนื้อหา "ขั้นสูง" (conditional type, infer) ถ้าคุณเป็นมือใหม่ ข้ามไป Part 9 ก่อนได้เลย ไม่กระทบการเขียน TypeScript ในชีวิตประจำวัน — อ่านผ่าน ๆ เข้าใจแนวคิดก็พอ แล้วค่อยกลับมาอ่านลึกเมื่อพร้อม สิ่งที่ต้องแม่นจริง ๆ ในบทนี้คือ Part 1-6

7.1 แนวคิด

Conditional type คือ type ที่มี "เงื่อนไข if-else" ในตัว ใช้รูปแบบ:

text
T extends U ? X : Y

อ่านว่า: "ถ้า T เข้ากันได้กับ U → ให้เป็น type X ไม่งั้น → ให้เป็น type Y" — เหมือน ternary operator (? :) แต่เป็นระดับ type

typescript
type IsString<T> = T extends string ? "ใช่ string" : "ไม่ใช่ string";

type A = IsString<"hello">;   // "ใช่ string"   (เพราะ "hello" เป็น string)
type B = IsString<42>;        // "ไม่ใช่ string" (เพราะ 42 ไม่ใช่ string)

7.2 ตัวอย่างที่มีประโยชน์จริง

typescript
// type ที่ลบ null และ undefined ออก
type NonNullable<T> = T extends null | undefined ? never : T;

type A = NonNullable<string | null>;       // string
type B = NonNullable<number | undefined>;  // number

อธิบาย: ถ้า T เป็น null หรือ undefined → กลายเป็น never (หายไป) ไม่งั้น → คงเดิม

7.3 พฤติกรรม "distributive" (กระจายตัวบน union)

📌 Distributive = "กระจายตัว" — เมื่อ conditional type เจอ union มันจะ แยกทำทีละสมาชิก แล้วรวมผลลัพธ์กลับเป็น union — เหมือนกระจายสูตรคูณเข้าวงเล็บในคณิตศาสตร์

มีพฤติกรรมพิเศษ — เมื่อ conditional type เจอ union มันจะ "แยกทำทีละตัว" แล้วรวมผลกลับ:

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

type Result = ToArray<string | number>;
// TypeScript แยกทำ: ToArray<string> | ToArray<number>
// = string[] | number[]

สังเกตว่าผลลัพธ์เป็น string[] | number[] (array ของ string หรือ array ของ number) ไม่ใช่ (string | number)[] — เพราะมัน "กระจายตัว" ทำทีละสมาชิกของ union

💡 T extends any กับ T extends unknown ต่างกันไหม? สำหรับ distributive behavior ทั้งสองให้ผลเหมือนกัน แต่บางทีมเลือก unknown เพราะ semantically ตรงกว่า (ทุก type เป็น subtype ของ unknown) และหลีกเลี่ยง lint rule @typescript-eslint/no-explicit-any ที่อาจฟ้องใน constraint context

ตอนนี้แค่รู้ว่ามีพฤติกรรมนี้ก็พอ — รายละเอียดอยู่ในบทที่ 10


Part 8: infer — "ดึง type ออกมาจากข้างใน"

infer เป็นความสามารถขั้นสูงสุดของบทนี้ ใช้คู่กับ conditional type

8.1 แนวคิด

infer หมายถึง "ฉันรู้ว่า type นี้มีบางอย่างซ่อนอยู่ข้างใน — ช่วยดึงมันออกมาให้หน่อย"

ตัวอย่าง — เรามี string[] (array ของ string) เราอยากดึง "type ของสมาชิก" (ก็คือ string) ออกมา:

typescript
type ElementType<T> = T extends Array<infer E> ? E : never;
//                                      ↑ infer E = "ดึง type ของสมาชิก array มาเรียกว่า E"

type A = ElementType<string[]>;    // string
type B = ElementType<number[]>;    // number
type C = ElementType<boolean>;     // never  (boolean ไม่ใช่ array)

อ่าน T extends Array<infer E> ? E : never ว่า: "ถ้า T เป็น array ของอะไรสักอย่าง — เรียกอะไรนั้นว่า E — แล้วคืน E; ถ้าไม่ใช่ array → คืน never"

8.2 ตัวอย่างที่ TypeScript ใช้จริง

TypeScript มี utility type หลายตัวที่สร้างด้วย infer ลองดูว่าเขาทำยังไง:

typescript
// ดึง return type ของฟังก์ชัน
type MyReturnType<F> = F extends (...args: any[]) => infer R ? R : never;

type A = MyReturnType<() => string>;              // string
type B = MyReturnType<(x: number) => boolean>;    // boolean

// ดึง type ที่ Promise ห่อไว้
type Unwrap<T> = T extends Promise<infer U> ? U : T;

type C = Unwrap<Promise<number>>;   // number
type D = Unwrap<string>;            // string (ไม่ใช่ Promise → คงเดิม)

ของจริง TypeScript มี ReturnType<F> และ Awaited<T> เตรียมไว้ให้แล้ว (เรียนบทที่ 06) — แต่ตอนนี้คุณเห็นแล้วว่าเบื้องหลังมันสร้างด้วย infer

🧠 ถ้าตอนนี้ Part 7-8 ยังงง ๆ ไม่ต้องกังวล — มันคือเนื้อหา "type-level programming" ที่แม้แต่มืออาชีพหลายคนก็ไม่ได้ใช้ทุกวัน เข้าใจแนวคิดคร่าว ๆ ไว้ แล้วบทที่ 10 จะลงลึกอีกครั้ง สิ่งที่ต้องแม่นจริง ๆ ในบทนี้คือ Part 1-6


Part 9: หลักการเขียน Generic ที่ดี

9.1 ใช้ constraint เมื่อต้องใช้ property ของ T

ถ้าในฟังก์ชันคุณต้องเข้าถึง property ของ T — ต้องใส่ constraint:

typescript
// ❌ ผิด
function getName<T>(item: T): string {
    return item.name;   // Error! T อาจไม่มี name
}

// ✅ ถูก
function getName<T extends { name: string }>(item: T): string {
    return item.name;   // OK
}

9.2 อย่าใช้ generic ที่ไม่จำเป็น

generic มีประโยชน์ก็ต่อเมื่อมันเชื่อม "input กับ output" เข้าด้วยกัน ถ้า T ถูกใช้ที่เดียว มันมักไม่จำเป็น:

typescript
// ❌ generic ที่ไร้ความหมาย — T ใช้ที่เดียว
function logValue<T>(value: T): void {
    console.log(value);
}
// เขียนแบบนี้ดีกว่า:
function logValue(value: unknown): void {
    console.log(value);
}

หลักการ: generic ควรปรากฏ อย่างน้อย 2 ที่ (เช่น ใน parameter และ return) เพื่อ "เชื่อม" ความสัมพันธ์

9.3 ตั้งชื่อให้สื่อความหมายเมื่อซับซ้อน

ธรรมเนียมคือใช้ T ตัวเดียวเมื่อ generic ง่าย ๆ — แต่เมื่อมีหลาย type parameter หรือซับซ้อน การตั้งชื่อสื่อความหมาย (เช่น TData, TError) จะช่วยให้อ่านเข้าใจง่ายขึ้นมาก:

typescript
// generic เดียว — T พอ
function first<T>(arr: T[]): T | undefined { /* ... */ }

// generic หลายตัวซับซ้อน — ตั้งชื่อยาวช่วยได้
type ApiHook<TData, TError, TParams> = { /* ... */ };

โซน 9.4–9.6: ขั้นสูง — สำหรับคนเขียน library เท่านั้น

🚩 หยุดอ่านก่อน — ส่วนนี้ไม่ใช่สำหรับมือใหม่

3 หัวข้อต่อไปนี้ (const type parameter, NoInfer, variance) เป็นเรื่องของ คนเขียน library (เช่นทีมที่ทำ React, Zod, tRPC) ที่ต้องรู้กลไกภายในของ TypeScript ลึก ๆ

  • ต้องใช้ TS เวอร์ชันใหม่: const T ต้อง TS 5.0+ / NoInfer<T> ต้อง TS 5.4+
  • คนใช้ทั่วไปไม่ต้องเขียนเอง — แค่รู้จักชื่อก็พอ ถ้าวันหนึ่งเจอในเอกสาร library จะได้ไม่ตกใจ
  • variance เป็นทฤษฎี type system ที่อ่านยากที่สุดในบทนี้ — รอบแรกอ่านผ่านได้

สิ่งที่ต้องแม่นในบทนี้คือ Part 1–6 + 9.1–9.3 ส่วน 9.4–9.6 กลับมาอ่านเมื่อพร้อม

9.4 const type parameter (TS 5.0+) — บอก TypeScript ว่า "อย่า widen"

📌 ต้อง TypeScript 5.0 ขึ้นไป (ออก มี.ค. 2023) — เวอร์ชันเก่ากว่านี้ใช้ไม่ได้

ปกติ TypeScript จะ "widen" type ของ argument:

typescript
function pick<T>(items: T[]): T {
    return items[0];
}

const fruit = pick(["apple", "banana", "cherry"]);
//    ^? string
// ทำไม T = string? เพราะ TS ถือว่า array เป็น "ของที่แก้ไขได้" (mutable)
// เลย widen สมาชิกแต่ละตัวจาก literal ("apple") ให้กว้างขึ้นเป็น string ก่อน
// แล้ว infer array ทั้งก้อนเป็น string[] → เมื่อ items: T[] และ argument คือ string[] → T = string
// (widening แบบเดียวกับที่ let x = "hello" กลายเป็น string เฉย ๆ แค่เกิดขึ้นกับสมาชิกใน array)

ใส่ const หน้า type parameter (TS 5.0+) → TypeScript จะ infer argument เป็น readonly tuple ของ literal (เหมือนเติม as const ให้อัตโนมัติ):

typescript
function pick<const T>(items: readonly T[]): T {
    return items[0];
}

const fruit = pick(["apple", "banana", "cherry"]);
//    ^? "apple" | "banana" | "cherry"   ← literal narrow แล้ว

ใช้บ่อยมากใน DSL builder (เครื่องมือสร้างภาษาคำสั่งเฉพาะงาน), type-safe routing, query builder ของ Drizzle/Prisma (library สำหรับต่อฐานข้อมูล) ที่ต้องการรู้ key ที่ user ส่งมาเป๊ะ ๆ ไม่ widen เป็น string

9.5 NoInfer<T> (TS 5.4+) — บอก TypeScript ว่า "อย่าเดาจากที่นี่"

📌 ต้อง TypeScript 5.4 ขึ้นไป (ออก มี.ค. 2024) — เวอร์ชันเก่ากว่านี้ไม่มี NoInfer

บางครั้งคุณอยากให้ generic ถูก infer จาก parameter หนึ่ง แต่ไม่อยากให้ parameter อีกตัวมามีอิทธิพล:

typescript
function createState<T>(initial: T, validator: (v: T) => boolean): T {
    if (!validator(initial)) throw new Error("invalid");
    return initial;
}

createState("hello", (v) => v.length > 0);
// T = string

ทำไม T ถึงกลายเป็น string ทั้งที่ argument ตัวแรกคือ "hello"? เพราะ validator parameter ก็ใช้ T อยู่ด้วย — TypeScript เลย "เดา" T จากสองแหล่ง (initial และ validator) แล้ว widen ให้กว้างพอรองรับทั้งคู่ กลายเป็น string ทั่วไป ทั้งที่จริง ๆ เราอาจอยากได้ T = "hello" เป๊ะ ๆ จาก initial เพียงแหล่งเดียว

ใส่ NoInfer<T> ที่ parameter ที่ไม่อยากให้ inference → TS เดา T จากตัวอื่นเท่านั้น (จากตัวอย่างนี้ T = "hello"):

typescript
function createState<T>(initial: T, validator: (v: NoInfer<T>) => boolean): T {
    if (!validator(initial)) throw new Error("invalid");
    return initial;
}
// T เดาจาก initial ตัวเดียว

เห็นผลชัดตรงไหน? ลองดูตัวอย่างที่ T ถูกเดาจากสองแหล่งที่ "ขัดกัน" — โดยไม่มี NoInfer:

typescript
function createState<T>(initial: T, validator: (v: T) => boolean): T {
    return initial;
}

const state = createState("hello", (v: string | number) => v.toString().length > 0);
// TS ต้องเดา T ที่ครอบคลุมทั้ง "hello" (จาก initial) และ string | number (จาก validator)
// → T กลายเป็น string | number แทนที่จะเป็น string เป๊ะ ๆ ตามที่ initial บอก

ถ้าใส่ NoInfer<T> ที่ validator — TypeScript จะเดา T จาก initial อย่างเดียว (T = string) แล้วเช็คว่า validator ใช้ได้กับ string จริงไหม ถ้า validator เขียนรับ type ที่ไม่ตรงกับ T ที่เดาได้ (เช่นรับแต่ number) ก็จะเกิด error ให้เห็นทันที — นี่คือประโยชน์จริงของ NoInfer: บังคับให้ T มาจากแหล่งเดียวที่ตั้งใจ แล้วให้ TypeScript ตรวจ พารามิเตอร์อื่นตาม T นั้น แทนที่จะปล่อยให้มันมามีเสียงในการเดา T เอง

ใช้บ่อยใน factory function (ฟังก์ชันที่สร้าง object) ของ form library (ไลบรารีจัดการฟอร์ม), state machine builder (เครื่องสร้างตัวจัดการสถานะ), schema validator (ตัวตรวจสอบโครงสร้างข้อมูล)

9.6 Variance — function parameter ที่กลับด้านความคิด

🧠 อ่านยากที่สุดในบทนี้ — ถ้ารอบแรกไม่เข้าใจ ข้ามไป Part 10 ได้ ไม่เสียหาย

หัวข้อขั้นสูงสุดของ Part 9 — เกี่ยวกับ "ความสัมพันธ์ของ subtype เปลี่ยนทิศตอนอยู่ใน function parameter"

📌 ศัพท์ที่ต้องรู้ก่อน:

  • subtype = "ชนิดย่อย" — Dog เป็น subtype ของ Animal เพราะ Dog ทุกตัวก็เป็น Animal (ลูกของ Animal)
  • Variance = "ทิศทางการเข้ากันได้ของ type" — เมื่อมีความสัมพันธ์พ่อ-ลูก ระหว่าง type สอง type แล้ว type ที่ "ห่อ" มัน (เช่น array, function) จะรักษาทิศทางนั้น กลับทิศ หรือ ยอมทั้งสองทาง
  • Covariance = "ทิศตามกัน" — ลูกเข้าได้ที่พ่อ (ปกติ)
  • Contravariance = "ทิศกลับกัน" — พ่อเข้าได้ที่ลูก (สวนทาง)
  • Bivariance = "ยอมทั้ง 2 ทาง" — ลูก ↔ พ่อ ผ่านได้หมด (หละหลวมที่สุด)

Covariance — "ทิศตามกัน" (ลูกเข้าได้ที่พ่อ)Dog เป็น subtype ของ AnimalDog[] ก็เป็น subtype ของ Animal[]:

typescript
class Animal { name = ""; }
class Dog extends Animal { bark() {} }

const dogs: Dog[] = [new Dog()];
const animals: Animal[] = dogs;   // ✅ OK — covariant

Contravariance — "ทิศกลับกัน" (พ่อเข้าได้ที่ลูก) — function parameter ทำงานสวนทาง:

typescript
type Handler<T> = (x: T) => void;

const handleAnimal: Handler<Animal> = (a) => console.log(a.name);
const handleDog: Handler<Dog> = (d) => d.bark();

// "function ที่รับ Animal ได้ ก็รับ Dog ได้" (Dog เป็น Animal อยู่แล้ว)
const fn1: Handler<Dog> = handleAnimal;   // ✅ OK — contravariant
const fn2: Handler<Animal> = handleDog;   // ❌ Error — handleDog เรียก .bark() ที่ Animal ไม่มี

ตรรกะเป็น 3 ขั้น:

  1. ถ้าคุณต้องการ "function ที่รู้จัก Dog" (Handler<Dog>) — คนเรียกจะส่ง Dog ให้ function นั้นแน่นอน
  2. ใส่ handleAnimal แทนได้ เพราะฟังก์ชันที่รับ Animal ได้ ก็รับ Dog ได้อยู่แล้ว (Dog เป็น Animal)
  3. แต่ถ้าเอา handleDog ไปใส่ในที่ที่คาดว่ารับ Handler<Animal> — คนเรียกจะส่ง new Animal() ให้ ซึ่งไม่มี .bark() → พัง

💡 อย่าเหมาเข่ง: ทิศทางของ variance ขึ้นอยู่กับว่า T อยู่ตำแหน่ง input (parameter) หรือ output (return) — อยู่ output จะเป็น covariant (แบบ Dog[]/Animal[] ด้านบน) อยู่ input จะเป็น contravariant (แบบ Handler<T> นี้) อย่าเอาตัวอย่าง Handler<T> ไปสรุปว่า generic ทุกตัวเป็น contravariant หมด

💡 สรุปง่าย ๆ ก่อนอ่านรายละเอียด: covariance = ยืดหยุ่นด้าน output (ลูกเข้าได้ที่พ่อ), contravariance = ยืดหยุ่นด้าน input (พ่อเข้าได้ที่ลูก), bivariance = ยืดหยุ่นทั้งสองด้าน (ปลอดภัยน้อยที่สุด)

TypeScript flag ที่เปลี่ยนพฤติกรรม:

  • --strictFunctionTypes (เปิดอัตโนมัติเมื่อใช้ --strict) → function parameter เป็น contravariant (ทิศกลับ) ตามทฤษฎี
  • method declaration ("ฟังก์ชันที่เขียนแบบ method ใน interface" เช่น { on(): void }) ยังคงเป็น bivariant (ยอมทั้ง 2 ทาง) เพื่อ backward compatibility (ความเข้ากันได้กับโค้ดเก่า) — TypeScript เลือกไม่ทำลายโค้ดเก่าที่มีอยู่จำนวนมาก — ระวังเวลาเขียน interface สำหรับ event handler
typescript
interface Bus {
    // ⚠️ method syntax — bivariant (ยอมทั้ง 2 ทาง) แม้เปิด strictFunctionTypes
    on(cb: (msg: string) => void): void;

    // ✅ property syntax — contravariant (ทิศกลับ) เคร่งครัด
    onSafe: (cb: (msg: string) => void) => void;
}

ในงาน React/Vue/Angular event handler ที่ TS ดูเหมือนยอมรับ subtype ไม่ตรง — มักเป็นเพราะ method syntax นี้

🧠 สรุปสั้น ๆ ของ Part 9.6: function parameter "กลับทิศ" ของ subtype (contravariance) แต่ TypeScript ยอมความหลวมไว้กับ method syntax (bivariance) เพื่อให้โค้ดเก่ายังคอมไพล์ผ่าน — ถ้ายังงงไม่ต้องเสียเวลา จดไว้ว่า "วันที่เจอ error แปลก ๆ เรื่อง function type ค่อยกลับมาอ่าน"


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

Lab 1 — Generic function

ฝึกเขียน generic function — ฟังก์ชันที่ทำงานได้กับทุกชนิด array โดยยังรักษา type ของสมาชิกไว้ (ใส่ number[] คืน number, ใส่ string[] คืน string):

typescript
// ฟังก์ชันที่คืนสมาชิกตัวสุดท้ายของ array
function last<T>(arr: T[]): T | undefined {
    return arr[arr.length - 1];
}

console.log(last([1, 2, 3]));         // 3 (type: number | undefined)
console.log(last(["a", "b", "c"]));   // "c" (type: string | undefined)

Lab 2 — Generic class

ฝึกเขียน generic class — คลาสที่เก็บค่าชนิดใดก็ได้แต่ผูก type ไว้ตอนสร้าง (Box<number> เก็บได้แต่ number) เห็นภาพการนำ generic ไปใช้กับโครงสร้างข้อมูล:

typescript
class Box<T> {
    constructor(private content: T) {}

    get(): T {
        return this.content;
    }

    map<U>(fn: (value: T) => U): Box<U> {
        return new Box(fn(this.content));
    }
}

const numberBox = new Box(10);
const stringBox = numberBox.map(n => `ค่าคือ ${n}`);
console.log(stringBox.get());   // "ค่าคือ 10"

Lab 3 — keyof constraint

typescript
function pluck<T, K extends keyof T>(items: T[], key: K): T[K][] {
    return items.map(item => item[key]);
}

const users = [
    { name: "Alice", age: 30 },
    { name: "Bob", age: 25 },
];

const names = pluck(users, "name");   // type: string[]  → ["Alice", "Bob"]
const ages = pluck(users, "age");     // type: number[]  → [30, 25]
const x = pluck(users, "email");      // ❌ Error! ไม่มี key "email"

Part 11: Checkpoint

1. Generic ดีกว่า any ตรงไหน?

ทั้งคู่ยืดหยุ่นรับ type อะไรก็ได้ แต่ any ปิดการตรวจสอบทั้งหมด (เสีย type safety) ส่วน generic ยังคงความสัมพันธ์ของ type ไว้ — ส่ง string[] เข้า ได้ string กลับ และ TypeScript ยังปกป้องเราอยู่

2. <T> ในฟังก์ชัน generic คืออะไร?

คือ "ช่องว่างสำหรับ type" คล้ายพารามิเตอร์ของฟังก์ชันที่เป็นช่องว่างสำหรับค่า — TypeScript จะเติม type จริงลงในช่อง T ตอนเรียกใช้

3. <T extends X> หมายความว่าอะไร?

เป็น constraint — บอกว่า T เป็นอะไรก็ได้ "ตราบใดที่เข้ากันได้กับ X" ใช้ตั้งเงื่อนไขขั้นต่ำ เช่น T extends { length: number } = T ต้องมี length

4. keyof T กับ T[K] ทำอะไร?

keyof T ดึงรายชื่อ key ทั้งหมดของ object type T ออกมาเป็น union ของ literal T[K] ดึง type ของ property ตัวที่ชื่อ K ออกมา — ใช้คู่กันทำฟังก์ชันอ่าน property ที่ปลอดภัย

5. Conditional type คืออะไร?

type ที่มีเงื่อนไข if-else ในตัว รูปแบบ T extends U ? X : Y — ถ้า T เข้ากับ U ได้ผลเป็น X ไม่งั้นเป็น Y

6. infer ใช้ทำอะไร?

ใช้คู่กับ conditional type เพื่อ "ดึง type ที่ซ่อนอยู่ข้างใน" ออกมา เช่นดึง type ของสมาชิก array หรือ return type ของฟังก์ชัน

7. ทำไมในไฟล์ .tsx ต้องเขียน <T,> (มี comma)?

เพราะใน .tsx ถ้าเขียน <T> เฉย ๆ TypeScript จะนึกว่าเป็น JSX tag — ใส่ comma <T,> เพื่อบอกว่าเป็น generic ไม่ใช่ JSX


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

  • Generic = "พารามิเตอร์สำหรับ type" — เขียนโค้ดครั้งเดียว ใช้ได้ทุก type โดยไม่เสีย type safety
  • ใช้ได้กับ ฟังก์ชัน, interface, type, class
  • Constraint (extends) — ตั้งเงื่อนไขขั้นต่ำให้ generic
  • keyof + T[K] — ทำงานกับ key ของ object อย่างปลอดภัย (สำคัญมาก)
  • TypeScript เดา type ของ generic ให้เอง ส่วนใหญ่ไม่ต้องระบุ
  • Conditional type (T extends U ? X : Y) และ infer — เทคนิคขั้นสูง ค่อยลงลึกในบทที่ 10
  • สิ่งที่ต้องแม่นในบทนี้: Part 1-6 ส่วน Part 7-8 เข้าใจแนวคิดพอ

บทถัดไป — Utility Types ซึ่งคือ generic สำเร็จรูปที่ TypeScript เตรียมไว้ให้


← บทที่ 04 | สารบัญ | บทที่ 06: Utility Types →


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