Skip to content

บทที่ 04 — Object Types และ Interface

← บทที่ 03 | สารบัญ | บทที่ 05: Generics Deep →

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


บทนี้สำคัญแค่ไหน

ในการเขียนโปรแกรมจริง 90% ของ type ที่คุณสร้างจะเป็น type ของ object — เพราะข้อมูลในโลกจริงมักมาเป็นกลุ่ม: ผู้ใช้คนหนึ่งมีชื่อ+อายุ+อีเมล, สินค้าชิ้นหนึ่งมีชื่อ+ราคา+จำนวน

บทนี้จะสอนวิธี "อธิบายรูปร่างของ object" อย่างละเอียด รวมถึงตอบคำถามที่มือใหม่ถามบ่อยที่สุดข้อหนึ่ง — "type กับ interface ใช้อันไหนดี?"


Part 1: อธิบายรูปร่างของ Object

1.1 สองวิธีในการตั้งชื่อ type ของ object

เราอธิบายรูปร่างของ object ได้ 2 วิธี ซึ่งให้ผลเกือบเหมือนกัน:

typescript
// วิธีที่ 1 — type alias
type User = {
    name: string;
    age: number;
};

// วิธีที่ 2 — interface
interface User {
    name: string;
    age: number;
}

ความต่างของไวยากรณ์:

  • type มีเครื่องหมาย = และมักปิดท้ายด้วย ;
  • interface ไม่มี = ใช้วงเล็บปีกกาตรง ๆ

ทั้งคู่นำไปใช้เหมือนกัน:

typescript
const alice: User = {
    name: "Alice",
    age: 30,
};

ถ้า object ที่ใส่ไม่ตรงกับ type — ขาด field, เกิน field, หรือ type ผิด — TypeScript จะฟ้องทันที:

typescript
const bob: User = {
    name: "Bob",
    // ❌ Error! ขาด property 'age'
};

const charlie: User = {
    name: "Charlie",
    age: "30",   // ❌ Error! age ต้องเป็น number ไม่ใช่ string
};

1.2 ตัวคั่นใช้ ; หรือ , ก็ได้

ภายในวงเล็บปีกกา คุณจะคั่น property ด้วย ; หรือ , ก็ได้ (หรือขึ้นบรรทัดใหม่เฉย ๆ ก็ได้) — แต่ convention นิยมใช้ ;:

typescript
type Point = { x: number; y: number };   // นิยมแบบนี้

Part 2: Property Modifiers — ตัวกำกับ property

property แต่ละตัวใส่ "ตัวกำกับพิเศษ" ได้ มี 2 ตัวที่ใช้บ่อยมาก

2.1 Optional Property — ? (มีหรือไม่มีก็ได้)

ใส่ ? หลังชื่อ property เพื่อบอกว่า "property นี้จะมีหรือไม่มีก็ได้":

typescript
type User = {
    name: string;
    age: number;
    nickname?: string;   // ← ไม่บังคับ
};

const a: User = { name: "Alice", age: 30 };                    // ✅ ไม่มี nickname ก็ได้
const b: User = { name: "Bob", age: 25, nickname: "บ๊อบ" };    // ✅ มีก็ได้

⚠️ สิ่งที่ต้องระวัง: เมื่อใส่ ? type ของ property นั้นจะกลายเป็น string | undefined โดยอัตโนมัติ — ดังนั้นเวลาใช้งานต้องเช็กก่อน:

typescript
function greet(user: User) {
    user.nickname.toUpperCase();   // ❌ Error! nickname อาจเป็น undefined

    if (user.nickname) {
        user.nickname.toUpperCase();   // ✅ เช็กแล้ว ปลอดภัย
    }
}

2.2 Readonly Property — readonly (กำหนดได้ครั้งเดียว)

ใส่ readonly หน้าชื่อ property เพื่อบอกว่า "property นี้กำหนดค่าตอนสร้าง object ได้ครั้งเดียว หลังจากนั้นแก้ไม่ได้":

typescript
type User = {
    readonly id: number;   // ← แก้ไม่ได้หลังสร้าง
    name: string;
};

const user: User = { id: 1, name: "Alice" };

user.name = "Alicia";   // ✅ name แก้ได้
user.id = 999;          // ❌ Error! id เป็น readonly

readonly มีประโยชน์กับข้อมูลที่ "ไม่ควรเปลี่ยน" เช่น id ของ object

⚠️ ข้อควรรู้: readonly มีผลแค่ตอน compile-time เท่านั้น (จำ type erasure จากบทที่ 00 ได้ไหม) — ตอน runtime จริง ๆ ค่ายังเปลี่ยนได้ มันเป็นเพียง "การปกป้องระดับ type" ที่ช่วยกันไม่ให้เราเผลอแก้โดยไม่ตั้งใจ ไม่ใช่การล็อกค่าจริง ๆ

2.3 Nested Object — object ซ้อน object

object มี field ที่เป็น object อีกทีได้:

typescript
type User = {
    name: string;
    address: {
        city: string;
        country: string;
    };
};

const user: User = {
    name: "Alice",
    address: {
        city: "Bangkok",
        country: "TH",
    },
};

แต่ถ้า object ซ้อนเริ่มซับซ้อน แนะนำให้แยกตั้งชื่อ type ของ object ข้างในออกมาต่างหาก — อ่านง่ายกว่าและนำไปใช้ซ้ำได้:

typescript
type Address = {
    city: string;
    country: string;
};

type User = {
    name: string;
    address: Address;   // ← ใช้ type ที่ตั้งชื่อไว้
};

Part 3: type vs interface — เลือกอันไหน (คำถามยอดฮิต)

นี่คือคำถามที่มือใหม่ทุกคนถาม มาตอบให้จบในบทนี้

3.1 สิ่งที่ทั้งคู่ทำได้เหมือนกัน

สำหรับการอธิบาย object ธรรมดา ทั้ง type และ interface ทำได้เหมือนกันทุกประการ ทั้งคู่:

  • อธิบาย object ได้
  • ใส่ optional / readonly ได้
  • ขยายต่อ (extends) ได้
  • ใช้กับ class (implements) ได้

ในกรณีทั่วไป เลือกอันไหนก็ได้ ไม่ผิด

💡 มีความต่างเล็ก ๆ ที่ลึกกว่านี้อีก (เช่น performance ของ compiler ตอนเช็ก type ขนาดใหญ่มาก ๆ interface มักเร็วกว่าเพราะ cache ได้ดีกว่า) แต่ระดับนี้ไม่กระทบการใช้งานทั่วไป ไม่ต้องกังวลตอนเริ่มต้น

3.2 สิ่งที่ type ทำได้แต่ interface ทำไม่ได้

type ยืดหยุ่นกว่า — มันตั้งชื่อให้ "อะไรก็ได้" ไม่ใช่แค่ object:

typescript
// type ตั้งชื่อให้ union ได้
type Status = "active" | "inactive";

// type ตั้งชื่อให้ primitive ได้
type UserId = string;

// type ตั้งชื่อให้ tuple ได้
type Coordinate = [number, number];

// type ตั้งชื่อให้ฟังก์ชันได้
type Handler = (event: string) => void;

ทั้งหมดนี้ interface ทำไม่ได้ — interface ใช้กับ object (และฟังก์ชัน) เท่านั้น

3.3 สิ่งที่ interface ทำได้แต่ type ทำไม่ได้

interface มีความสามารถพิเศษเรียกว่า declaration merging (การประกาศซ้ำแล้วรวมกัน) — ประกาศชื่อเดียวกันหลายครั้ง แล้ว TypeScript จะ "รวม" ให้:

typescript
// ทั้งสอง interface อยู่ใน scope เดียวกัน (เช่น ไฟล์เดียวกัน)
interface User {
    name: string;
}
interface User {
    age: number;
}
// TypeScript รวมเป็น: User = { name: string; age: number }

ถ้าทำแบบนี้กับ type จะ error ทันที ("Duplicate identifier")

⚠️ ข้อจำกัดที่ต้องรู้: declaration merging แบบเขียนชื่อซ้ำตรง ๆ ทำได้เฉพาะเมื่อทั้งสอง interface อยู่ใน scope การประกาศเดียวกัน — ซึ่งรวมถึง "global scope" ข้ามไฟล์ด้วย (ไม่ใช่แค่ไฟล์เดียวกันตรง ๆ) นี่คือเหตุผลที่ตัวอย่าง declare global ข้างล่างนี้ (ขยาย Window จากไฟล์ไหนก็ได้ในโปรเจกต์) ใช้งานได้จริง — ถ้าอยากเพิ่ม field ให้ interface ของ library ที่ import เข้ามา (ไม่ใช่ global) ต้องใช้เทคนิคชื่อ module augmentation (การขยาย type ของ library ที่มีอยู่) (เรียนบทที่ 09)

declaration merging ฟังดูแปลก แต่มีประโยชน์จริงในกรณีหนึ่ง — การ "เพิ่ม type ให้ของที่มีอยู่แล้ว" เช่น เพิ่ม property ให้ window:

typescript
declare global {
    interface Window {
        myAppConfig: { version: string };
    }
}

// หลังจากนี้ TypeScript รู้จัก window.myAppConfig
window.myAppConfig.version;

📝 ตัวอย่าง declare global ข้างบนใช้ทั้ง declare และ global ที่มือใหม่ยังไม่รู้จัก — ตอนนี้แค่ดูภาพรวมว่า "นี่คือวิธีบอก TypeScript ว่า window มี field เพิ่ม" พอ ส่วน syntax เต็ม ๆ เรียนใน บทที่ 09

⚠️ อย่าเพิ่งลองรันโค้ดนี้เอง — โค้ดแบบนี้ต้องอยู่ในไฟล์ประเภทเฉพาะ (เช่นไฟล์ .d.ts) ถึงจะทำงานได้จริง วางในไฟล์ .ts ธรรมดาแล้วจะไม่ได้ผลตามที่อธิบาย รายละเอียดเต็ม ๆ อยู่ในบทที่ 09

3.4 สรุปคำแนะนำ

สถานการณ์ใช้
อธิบาย object ทั่วไปอันไหนก็ได้
union, tuple, primitive alias, function typetype (interface ทำไม่ได้)
เขียน library สาธารณะที่คนอื่นอาจอยากขยายinterface
ต้องการ declaration merging / augment libraryinterface

🎯 คำแนะนำสำหรับมือใหม่: อย่าเสียเวลากับเรื่องนี้มาก — เลือก type เป็นค่าเริ่มต้น เพราะมันทำได้ทุกอย่าง พอเจอกรณีที่ต้องใช้ interface (library, declaration merging) ค่อยใช้ ความสม่ำเสมอสำคัญกว่าการเลือกถูก "ตามทฤษฎี"


Part 4: ขยาย type ด้วย Extends และ Intersection

เมื่อมี type อยู่แล้ว และอยากสร้าง type ใหม่ที่ "มีของเดิมทั้งหมด + เพิ่มของใหม่" มี 2 วิธี

4.1 extends (สำหรับ interface)

typescript
interface Animal {
    name: string;
    eat(): void;
}

interface Dog extends Animal {
    bark(): void;
}
// Dog มี: name, eat(), และ bark()

const myDog: Dog = {
    name: "เรกซ์",
    eat() { console.log("กินข้าว"); },
    bark() { console.log("โฮ่ง"); },
};

interface Dog extends Animal หมายถึง "Dog มีทุกอย่างของ Animal บวกกับของที่เพิ่มเอง"

ขยายจากหลาย interface พร้อมกันก็ได้:

typescript
interface Pet {
    owner: string;
}

interface FamilyDog extends Animal, Pet {
    // มีทั้งของ Animal และ Pet
}

4.2 Intersection & (สำหรับ type)

ถ้าใช้ type ให้ใช้ & (intersection ที่เรียนในบทที่ 02) เพื่อรวม:

typescript
type Animal = {
    name: string;
};

type Dog = Animal & {
    bark(): void;
};
// Dog มี: name และ bark()

ผลลัพธ์เหมือน extends ทุกประการ — Dog มีของทั้งสองฝั่งรวมกัน

💡 จำง่าย ๆ: interface ใช้ extends, type ใช้ & — ทำงานคล้ายกัน


Part 5: Index Signature — object ที่ไม่รู้ key ล่วงหน้า

เราเจอเรื่องนี้สั้น ๆ ในบทที่ 02 มาดูให้ลึกขึ้น

บางครั้ง object ของเรา "ไม่รู้ล่วงหน้าว่าจะมี key อะไรบ้าง" รู้แค่ "key เป็นแบบไหน และ value เป็นแบบไหน" — เช่น ตารางแปลภาษา:

typescript
type Translations = {
    [key: string]: string;
    //  ↑           ↑ value เป็น string
    //  key เป็น string อะไรก็ได้
};

const th: Translations = {
    hello: "สวัสดี",
    goodbye: "ลาก่อน",
    thanks: "ขอบคุณ",
};

th["welcome"] = "ยินดีต้อนรับ";   // ✅ เพิ่ม key ใหม่ได้

ส่วน [key: string]: string คือ index signaturekey เป็นแค่ชื่อที่ตั้งเอง (จะตั้งว่าอะไรก็ได้) สิ่งสำคัญคือ type ของ key และ value

5.1 ผสม property ที่รู้แน่ ๆ กับ index signature

typescript
type ApiResponse = {
    status: number;              // property ที่รู้แน่ ๆ
    [key: string]: unknown;      // property อื่น ๆ ที่ไม่รู้
};

⚠️ ข้อจำกัดที่ต้องเข้าใจให้แม่น: type ของ property ที่รู้แน่ ๆ ต้องเป็น sub-type ของ value type ใน index signature — ไม่ใช่แค่ "เข้ากันได้" หลวม ๆ

ตัวอย่างที่ ไม่ผ่าน:

typescript
type Bad = {
    status: number;              // ❌ Error!
    [key: string]: string;       // index signature บอกว่า value ต้องเป็น string
};
// Property 'status' of type 'number' is not assignable to
// 'string' index type 'string'.

เหตุผล: ถ้า index signature ระบุว่า "key เป็น string ตัวไหนก็ตามจะได้ value เป็น string" — แต่ status กลับเป็น number ก็ขัดกับสัญญาที่ index signature ให้ไว้ TypeScript จึงไม่ยอม

ตัวอย่างที่ ผ่าน:

typescript
type Good1 = {
    status: number;
    [key: string]: number;       // ✅ number เป็น sub-type ของ number
};

type Good2 = {
    status: number;
    [key: string]: unknown;      // ✅ number เป็น sub-type ของ unknown
};

จุดสำคัญคือ — เวลาผสม property ที่รู้แน่ ๆ กับ index signature ให้ใช้ unknown หรือ union type กว้าง ๆ ใน index signature จะปลอดภัยที่สุด

💡 ในทางปฏิบัติ คนนิยมใช้ utility type ชื่อ Record แทน index signature เพราะอ่านง่ายกว่า — Record<string, string> มีความหมายเท่ากับ { [key: string]: string } (เรียนบทที่ 06)


Part 6: Excess Property Check (การตรวจ property เกิน) — เรื่องที่มือใหม่งงมาก

นี่เป็นพฤติกรรมของ TypeScript ที่ทำให้มือใหม่งงบ่อยที่สุดเรื่องหนึ่ง ขออธิบายช้า ๆ

6.1 ปรากฏการณ์ที่น่างง

typescript
type User = {
    name: string;
};

// กรณี A — ใส่ object ตรง ๆ
const a: User = { name: "Alice", age: 30 };
// ❌ Error! Object literal may only specify known properties, 'age' does not exist

// กรณี B — ผ่านตัวแปรก่อน
const temp = { name: "Bob", age: 30 };
const b: User = temp;
// ✅ ไม่ error! ทั้งที่ temp ก็มี age เกินมาเหมือนกัน

ทำไมกรณี A error แต่กรณี B ไม่ error ทั้งที่ดูเหมือนกัน?

6.2 คำอธิบาย

TypeScript มีกฎพิเศษชื่อ Excess Property Check (การตรวจ property เกิน) — กฎนี้ทำงานเฉพาะเมื่อคุณใส่ object ที่เขียนตรงๆ ด้วย {} (เรียกว่า object literal) ลงไปในที่ที่มี type กำกับ

ทำไมเช็ก object literal แต่ไม่เช็กตัวแปร? มันคือการ trade-off ที่ทีม TypeScript ออกแบบมาด้วยเหตุผล:

  • กรณี object literal ตรง ๆ{ name: "Alice", age: 30 } ที่เขียนปุ๊บใช้ปั๊บ ไม่มีใครอื่นใช้ ถ้ามี field เกินมาแสดงว่า "น่าจะพิมพ์ผิด" ลองคิดดู — ถ้าเราตั้งใจใส่ age จริง ๆ ทำไมไม่ใส่ age ไว้ใน type ด้วยเลย? field ที่เกินมาแบบนี้ไม่มีใครอ่านต่อ ไม่มีคุณค่าอะไร เพราะฉะนั้น TypeScript จึงเตือนไว้ก่อน ถือเป็นการ "ดักจับการพิมพ์ผิด" ให้เรา
  • กรณีผ่านตัวแปรก่อนtemp เป็นตัวแปรที่มี "ตัวตน" ของมันเอง อาจมีโค้ดอื่นใช้ temp.age อยู่ก็ได้ — การที่ User ต้องการแค่ name แล้ว temp มี name ครบ ก็ใช้แทน User ได้ตามหลัก structural typing ปกติ ส่วน age ที่เกินมาเป็นเรื่องของ temp ไม่ใช่เรื่องของ User

พูดอีกแบบ — Excess Property Check ไม่ใช่กฎ type ปกติ แต่เป็น "linter ที่ฝังในตัว compiler" สำหรับดักการพิมพ์ผิดในจุดที่มีความเสี่ยงสูง การเช็กตัวแปรทุกตัวด้วยจะเข้มงวดเกินไปและขัดกับหลัก structural typing ของภาษา

6.3 สรุปกฎ

📌 Excess Property Check ทำงานเฉพาะกับ object literal ที่เขียนตรง ๆ — ถือเป็น "ตาข่ายดักจับการพิมพ์ผิด" เมื่อเราเขียน object ใหม่ ๆ

เรื่องเดียวกันนี้เกิดตอนส่ง object เข้าฟังก์ชันด้วย:

typescript
function saveUser(user: User) {}

saveUser({ name: "Alice", age: 30 });   // ❌ Error (object literal ตรง ๆ)

const temp = { name: "Bob", age: 30 };
saveUser(temp);                          // ✅ (ผ่านตัวแปร)

6.4 ถ้าตั้งใจมี property เกินจริง ๆ

ถ้าคุณ "ตั้งใจ" ให้มี property เกิน วิธีที่ถูกต้องคือแก้ที่ type เอง — เช่นเพิ่ม age? หรือใช้ index signature ส่วนการใช้ as เพื่อ "ปิดปาก" error นั้น ไม่แนะนำ เพราะมันคือการข้ามการตรวจสอบ


Part 7: satisfies ("ตรงตามนี้" — ตรวจให้เข้ากันได้โดยไม่บังคับ type) — เครื่องมือที่ควรรู้จัก

satisfies เป็นคีย์เวิร์ดที่มีประโยชน์มากแต่มือใหม่มักไม่รู้จัก ขออธิบายด้วยปัญหาจริง

📌 ต้องใช้ TypeScript 4.9 ขึ้นไป (ออกปี 2022) — เวอร์ชันเก่ากว่านี้จะไม่รู้จักคีย์เวิร์ดนี้ ปัจจุบันโปรเจกต์ใหม่ใช้ TS 5.x กันหมดแล้ว ไม่ต้องกังวล

7.1 ปัญหา

สมมติเรามี type นี้ และอยากสร้าง config:

typescript
type Theme = "dark" | "light";

type Config = {
    theme: Theme;
    retries: number;
};

ทางเลือกที่ 1 — ใส่ annotation : Config:

typescript
const config: Config = {
    theme: "dark",
    retries: 3,
};

config.theme;   // type: "dark" | "light"  ← เสียความเป๊ะ! เรารู้ว่ามันคือ "dark" เป๊ะ ๆ แต่ TS บอกแค่ Theme (กว้างกว่า)

ปัญหา: annotation ทำให้ TypeScript "มอง config ผ่านแว่นของ Config" — theme เลยกลายเป็น union กว้าง "dark" | "light" ทั้งที่ความจริงเรารู้ว่ามันคือ "dark" เป๊ะ ๆ

ทางเลือกที่ 2 — ไม่ใส่ annotation:

typescript
const config = {
    theme: "dark",
    retries: 3,
};

config.theme;   // type: "dark"  ← เป๊ะดี

ปัญหา: คราวนี้ theme เป๊ะดี แต่ TypeScript ไม่ได้ตรวจว่า config ตรงกับ Config หรือเปล่า — ถ้าเราพิมพ์ retries ผิดเป็น retires ก็จะไม่มีใครเตือน

7.2 ทางออก — satisfies

satisfies ให้เราได้ทั้งสองอย่าง — ตรวจสอบว่าตรงกับ type และ ยังคงความเป๊ะของค่าไว้:

typescript
const config = {
    theme: "dark",
    retries: 3,
} satisfies Config;

config.theme;     // type: "dark"  ← เป๊ะ ✅ (ไม่ใช่ "dark" | "light" กว้าง ๆ)
// ถ้าพิมพ์ retries ผิด หรือ type ผิด → TypeScript ฟ้อง ✅

// ตัวอย่างการดักผิด:
const broken = {
    theme: "dark",
    retires: 3,        // ❌ Error! พิมพ์ผิด — should be 'retries'
} satisfies Config;

satisfies Config แปลว่า "ตรวจให้หน่อยว่า object นี้เข้ากันได้กับ Config ไหม — แต่อย่าเปลี่ยน type ที่เดาได้ของมัน"

🎯 กฎง่าย ๆ: เวลาสร้าง object คงที่ (config, ค่าตั้งต้น, lookup table) — ใช้ satisfies แทนการใส่ annotation : จะได้ทั้งการตรวจสอบและความเป๊ะ


Part 8: Discriminated Union (ยูเนียนแยกชนิด — มีตัวแยกระบุชนิด) — pattern ที่ทรงพลังที่สุด

นี่คือ pattern ที่สำคัญมากในการเขียน TypeScript ระดับมืออาชีพ ถ้าจะจำอะไรจากบทนี้สักอย่าง ให้จำอันนี้

8.1 ปัญหา

สมมติเราต้องเก็บข้อมูล "รูปทรง" ที่อาจเป็นวงกลม สี่เหลี่ยม หรือสามเหลี่ยม — แต่ละแบบมีข้อมูลต่างกัน:

  • วงกลม — มีรัศมี
  • สี่เหลี่ยมจัตุรัส — มีความยาวด้าน
  • สี่เหลี่ยมผืนผ้า — มีกว้างและสูง

8.2 ทางออก — Discriminated Union

เราสร้าง union ของหลาย object โดยที่ ทุก object มี field ตัวหนึ่งที่เป็น literal type ไว้ "ระบุชนิด" — field นั้นคือ "ตัวแยกแยะ" (ภาษาอังกฤษเรียก discriminator):

typescript
type Shape =
    | { kind: "circle"; radius: number }
    | { kind: "square"; side: number }
    | { kind: "rectangle"; width: number; height: number };
//      ↑ field "kind" คือ discriminator — แต่ละแบบมีค่า literal ต่างกัน

ทีนี้เวลาใช้งาน เมื่อเราเช็ก kind TypeScript จะ "รู้" ทันทีว่ากำลังจัดการกับรูปทรงแบบไหน และให้เราเข้าถึง field ที่ถูกต้อง:

typescript
function getArea(shape: Shape): number {
    switch (shape.kind) {
        case "circle":
            // ในนี้ TypeScript รู้ว่า shape คือวงกลม — เข้าถึง radius ได้
            return Math.PI * shape.radius ** 2;
        case "square":
            // ในนี้ shape คือสี่เหลี่ยมจัตุรัส — เข้าถึง side ได้
            return shape.side ** 2;
        case "rectangle":
            // ในนี้ shape คือสี่เหลี่ยมผืนผ้า — เข้าถึง width/height ได้
            return shape.width * shape.height;
    }
}

ลองเข้าถึง field ผิด TypeScript จะฟ้อง:

typescript
case "circle":
    return shape.side;   // ❌ Error! วงกลมไม่มี side

8.3 ทำไม pattern นี้ถึงทรงพลัง

เพราะมันทำให้ TypeScript "เข้าใจ state ที่ซับซ้อน" ได้อย่างปลอดภัย ตัวอย่างที่ใช้จริงตลอด — สถานะการโหลดข้อมูล:

ตัวอย่างข้างล่างนี้ตรึง data ให้เป็น string[] ไปเลย (รายการข้อมูลแบบข้อความ) เพื่อให้เห็นภาพชัด ๆ ก่อน — ยังไม่ต้องใช้ Generic:

typescript
type LoadingState =
    | { status: "loading" }
    | { status: "success"; data: string[] }
    | { status: "error"; message: string };

function render(state: LoadingState): string {
    switch (state.status) {
        case "loading":
            return "กำลังโหลด...";
        case "success":
            return `โหลดเสร็จ ${state.data.length} รายการ`;   // เข้าถึง data ได้
        case "error":
            return `ผิดพลาด: ${state.message}`;                 // เข้าถึง message ได้
    }
}

pattern นี้รับประกันว่า — คุณจะเข้าถึง data ได้ ก็ต่อเมื่อ status เป็น "success" เท่านั้น เป็นไปไม่ได้เลยที่จะเผลออ่าน data ตอนที่ยัง loading อยู่ (ซึ่งจะทำให้โปรแกรมพัง)

🔭 ในโค้ดจริง เรามักไม่อยากตรึง data ให้เป็น string[] เสมอไป — อยากให้เป็นชนิดอะไรก็ได้แล้วแต่ตอนใช้ นั่นคือหน้าที่ของ Generic (<T>) ซึ่งเป็นหัวข้อของบทถัดไป บทที่ 05 เช่น LoadingState<T> แล้วใช้เป็น LoadingState<string[]> — มองเป็นภาพรวมได้เลยตอนนี้

💡 เราจะเจอ discriminated union อีกหลายครั้งในบทที่ 07 (narrowing) และบทที่ 11 (งานจริง) — มันคือกระดูกสันหลังของการเขียน TypeScript ที่ปลอดภัย


Part 9: Result Type (แพตเทิร์น "ผลลัพธ์ที่อาจสำเร็จหรือล้มเหลว") — จัดการ error อย่างมี type

ต่อยอดจาก discriminated union — pattern หนึ่งที่นิยมมากคือ Result type สำหรับจัดการ "ผลลัพธ์ที่อาจสำเร็จหรือล้มเหลว":

typescript
type Result<T> =
    | { ok: true; value: T }
    | { ok: false; error: string };

แทนที่จะโยน exception (ที่ TypeScript มองไม่เห็นใน type) เราคืน Result ที่บังคับให้คนเรียกจัดการทั้งกรณีสำเร็จและล้มเหลว:

typescript
function divide(a: number, b: number): Result<number> {
    if (b === 0) {
        return { ok: false, error: "หารด้วยศูนย์ไม่ได้" };
    }
    return { ok: true, value: a / b };
}

const result = divide(10, 2);
if (result.ok) {
    console.log("ผลลัพธ์:", result.value);   // เข้าถึง value ได้
} else {
    console.log("ผิดพลาด:", result.error);   // เข้าถึง error ได้
}

ข้อดี: คนเรียก divide ไม่มีทางลืมจัดการกรณี error เพราะ TypeScript บังคับให้เช็ก result.ok ก่อนเข้าถึง result.value


Part 10: Branded Type (ชนิดติดตรา — แยก type ที่หน้าตาเหมือนกัน) — เกริ่นนำ

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

typescript
type UserId = string;
type OrderId = string;

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

const orderId: OrderId = "order-123";
getUser(orderId);   // 😱 ไม่ error! เพราะทั้งคู่คือ string เหมือนกัน

นี่คือบั๊กที่อันตราย — เราเผลอเอา id ของ order ไปใช้กับฟังก์ชันของ user

Branded type คือเทคนิคแก้ปัญหานี้ โดย "ติดตรา" (brand) ให้ type ต่างกัน:

📖 อ่าน syntax ทีละส่วน: string & { readonly __brand: "UserId" } หมายความว่า:

  • string — ต้องเป็น string ก่อน
  • & — AND (intersection — ต้องตรงทั้งสองฝั่ง)
  • { readonly __brand: "UserId" } — AND ต้องมี property __brand ที่มีค่าเป็น literal "UserId" เท่านั้น

ผลคือ UserId กับ OrderId ต่างก็เป็น string แต่ TypeScript มอง "ตรา" ที่ต่างกันแล้วถือว่าคนละชนิด

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

ตอนนี้ UserId กับ OrderId ไม่ใช่ string ธรรมดาเหมือนกันแล้ว — TypeScript มอง "ตรา" (brand) ที่ต่างกันแล้วแยกแยะได้

📝 syntax string & { readonly __brand: "UserId" } ใช้ intersection (&) + readonly + literal type — สำหรับมือใหม่บทที่ 4 ดูแล้วงงเป็นเรื่องปกติ ตอนนี้แค่ "รับรู้ว่ามีเทคนิคนี้อยู่" ก็พอ ส่วนทำไมต้องเขียนแบบนี้ และจะสร้าง branded value อย่างไรให้ปลอดภัย — เรียนเต็ม ๆ ใน บทที่ 10

เรื่องนี้ค่อนข้างขั้นสูง — บทที่ 10 จะอธิบายเต็ม ๆ ในบทนี้แค่รู้ว่ามีปัญหานี้ และมีทางแก้


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

Lab 1 — ออกแบบ type ระบบบทความ

ฝึกออกแบบ type ของระบบจริง — บทความที่มี author (object ซ้อน), tags (array), field ที่แก้ไม่ได้ (readonly) และ field ที่อาจไม่มี (?) รวมทุกแนวคิดเรื่อง object type เข้าด้วยกัน:

typescript
type Author = {
    readonly id: number;
    name: string;
    email?: string;
};

type Article = {
    readonly id: number;
    title: string;
    content: string;
    author: Author;
    tags: string[];
    publishedAt?: Date;   // ยังไม่เผยแพร่ก็ไม่มีค่านี้
};

const article: Article = {
    id: 1,
    title: "เรียน TypeScript",
    content: "...",
    author: { id: 10, name: "Alice" },
    tags: ["typescript", "เริ่มต้น"],
};

Lab 2 — Discriminated union สำหรับฟอร์ม

ฝึกใช้ "discriminated union" — union ของ object ที่มี field ร่วม (เช่น type) เป็นตัวแยก ทำให้ TypeScript narrow type ได้ใน switch และรู้ว่าแต่ละกรณีมี field อะไรบ้าง:

typescript
type FormField =
    | { type: "text"; value: string; maxLength: number }
    | { type: "number"; value: number; min: number; max: number }
    | { type: "checkbox"; value: boolean };

function describeField(field: FormField): string {
    switch (field.type) {
        case "text":
            return `ข้อความ (ยาวสุด ${field.maxLength})`;
        case "number":
            return `ตัวเลข (${field.min}–${field.max})`;
        case "checkbox":
            return `ติ๊กถูก (${field.value ? "เลือก" : "ไม่เลือก"})`;
    }
}

Lab 3 — Result type

typescript
type Result<T> =
    | { ok: true; value: T }
    | { ok: false; error: string };

function parseAge(input: string): Result<number> {
    const age = Number(input);
    if (Number.isNaN(age)) {
        return { ok: false, error: "ไม่ใช่ตัวเลข" };
    }
    if (age < 0 || age > 150) {
        return { ok: false, error: "อายุไม่สมเหตุสมผล" };
    }
    return { ok: true, value: age };
}

const r = parseAge("25");
console.log(r.ok ? `อายุ ${r.value}` : `ผิดพลาด: ${r.error}`);

Part 12: Checkpoint

1. type กับ interface ต่างกันหลัก ๆ ตรงไหน?

type ตั้งชื่อให้อะไรก็ได้ (union, tuple, primitive, function) ส่วน interface ใช้กับ object/function เท่านั้น แต่ interface ทำ declaration merging ได้ (ประกาศซ้ำแล้วรวมกัน) ซึ่ง type ทำไม่ได้

2. ใส่ ? ให้ property แล้ว type ของมันกลายเป็นอะไร?

กลายเป็น T | undefined — ดังนั้นต้องเช็ก undefined ก่อนใช้งานเสมอ

3. readonly ล็อกค่าจริง ๆ ตอน runtime ไหม?

ไม่ — readonly มีผลแค่ตอน compile-time (ช่วยกันเผลอแก้) ตอน runtime ค่ายังเปลี่ยนได้ เพราะ type ถูกลบทิ้งตอน compile

4. Excess Property Check ทำงานเมื่อไหร่?

ทำงานเฉพาะเมื่อใส่ object literal (เขียน {} ตรง ๆ) ลงในที่ที่มี type กำกับ — ถ้าผ่านตัวแปรก่อน กฎนี้จะไม่ทำงาน เป็นตาข่ายดักจับการพิมพ์ผิด

5. satisfies ดีกว่า annotation : ตรงไหน?

satisfies ตรวจสอบว่า object ตรงกับ type โดยไม่ทำให้เสียความเป๊ะของ type ที่เดาได้ (เช่น theme ยังเป็น "dark" ไม่ใช่ string) — เหมาะกับ object คงที่อย่าง config

6. Discriminated union คืออะไร ทำไมมีประโยชน์?

union ของหลาย object ที่ทุกตัวมี field "discriminator" เป็น literal type ไว้ระบุชนิด — เมื่อเช็ก discriminator แล้ว TypeScript narrow ให้เข้าถึง field ที่ถูกต้องของแต่ละชนิดได้อย่างปลอดภัย

7. Result type แก้ปัญหาอะไร?

ทำให้ "ผลลัพธ์ที่อาจล้มเหลว" มี type ชัดเจน บังคับให้คนเรียกจัดการทั้งกรณีสำเร็จและล้มเหลว — ต่างจากการโยน exception ที่ TypeScript มองไม่เห็น


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

  • อธิบาย object ด้วย type หรือ interfaceมือใหม่ใช้ type เป็นหลักได้เลย
  • ? = optional (type กลายเป็น T | undefined), readonly = แก้ไม่ได้ (compile-time เท่านั้น)
  • ขยาย type — interface ใช้ extends, type ใช้ &
  • Index signature — object ที่ไม่รู้ key ล่วงหน้า
  • Excess Property Check — ดักจับ property เกินกับ object literal ตรง ๆ
  • satisfies — ตรวจสอบ type โดยไม่เสียความเป๊ะ ใช้กับ config
  • Discriminated union — pattern ที่ทรงพลังที่สุด สำหรับจัดการ state หลายแบบอย่างปลอดภัย

บทถัดไป — Generics ซึ่งเป็นหัวใจของ TypeScript ระดับกลาง


← บทที่ 03 | สารบัญ | บทที่ 05: Generics Deep →