โหมดมืด
บทที่ 04 — Object Types และ Interface
⏱ ใช้เวลา 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 เป็น readonlyreadonly มีประโยชน์กับข้อมูลที่ "ไม่ควรเปลี่ยน" เช่น 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 type | type (interface ทำไม่ได้) |
| เขียน library สาธารณะที่คนอื่นอาจอยากขยาย | interface |
| ต้องการ declaration merging / augment library | interface |
🎯 คำแนะนำสำหรับมือใหม่: อย่าเสียเวลากับเรื่องนี้มาก — เลือก
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 signature — key เป็นแค่ชื่อที่ตั้งเอง (จะตั้งว่าอะไรก็ได้) สิ่งสำคัญคือ 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! วงกลมไม่มี side8.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 ระดับกลาง