TypeScript mapped types ช่วยให้สร้าง object type ใหม่ได้ด้วยการวนผ่าน key ของ type ที่มีอยู่แล้ว เช่น "shape เดียวกับ Course แต่ทุก property เป็น readonly" โดยอาศัย operator ของ type อีกไม่กี่ตัว ได้แก่ union, keyof และ indexed access เมื่อเข้าใจว่าส่วนเหล่านี้ประกอบกันอย่างไร คุณจะสร้าง type หนึ่งต่อจากอีก type ได้ แทนที่จะก๊อปนิยามที่เกือบเหมือนกันไปหลายที่แล้วต้องคอยแก้ตามทีละจุด
บทความนี้ครอบคลุม union และ intersection, discriminated union, keyof, indexed access, index signature, mapped type, key remapping และ template literal type ตัวอย่างทั้งหมด compile ผ่านบน TypeScript 5.x ที่เปิด strict
Union type: เป็นได้หนึ่งในหลาย type
Union type A | B หมายถึงค่านั้นเป็น type ใดก็ได้หนึ่งตัวจากรายการ จนกว่าจะ narrow คุณใช้ได้เฉพาะสมาชิกที่มีอยู่ในทุก type ของ union:
type Id = string | number;
function formatId(id: Id): string {
// id.toUpperCase() here would fail: it doesn't exist on number
return typeof id === "number"
? id.toString().padStart(6, "0")
: id.toUpperCase();
}รูปแบบที่ใช้บ่อยที่สุดคือ union ของ literal type เช่น "GET" | "POST" หรือ "sm" | "md" | "lg" ถ้ายังไม่คุ้นกับ literal type เริ่มได้ที่ TypeScript basic types
Intersection type: เป็นทุก type พร้อมกัน
Intersection A & B หมายถึงค่านั้นมีทุก property ของทั้งสอง type เป็นวิธีประกอบ object type จากชิ้นเล็ก ๆ:
type Timestamps = { createdAt: Date; updatedAt: Date };
type Product = { id: string; name: string; price: number };
type ProductRecord = Product & Timestamps;
// needs id, name, price, createdAt and updatedAtชื่อสองตัวนี้ชวนสับสนในช่วงแรก union ของ object type ได้ค่าที่มี property รับประกันได้ น้อยลง เพราะใช้ได้แค่ส่วนที่มีร่วมกัน ส่วน intersection ได้ค่าที่มี property มากขึ้น เพราะต้องผ่านเงื่อนไขของทั้งสองฝั่ง
ระวัง property ที่ขัดกัน ใน { id: string } & { id: number } ค่า id ต้องเป็นทั้ง string และ number พร้อมกัน type ของมันจึงกลายเป็น never และไม่มีค่าไหนผ่านได้ TypeScript ไม่ได้เตือนตรงจุดที่เขียน intersection คุณจะรู้ก็ต่อเมื่อ assign ค่าแล้ว error
Discriminated union: จำลอง state อย่างปลอดภัย
Discriminated union คือ union ของ object type ที่มี property แบบ literal ร่วมกันหนึ่งตัว เรียกว่า discriminant การเช็ก property นี้จะ narrow ทั้ง object นี่คือวิธีที่สะอาดที่สุดในการจำลองข้อมูลที่อยู่ได้ทีละหนึ่ง state:
type RequestState<T> =
| { status: "idle" }
| { status: "loading" }
| { status: "success"; data: T }
| { status: "error"; error: string };
function render(state: RequestState<User[]>): string {
switch (state.status) {
case "idle":
return "Nothing loaded yet";
case "loading":
return "Loading...";
case "success":
return `${state.data.length} users`; // data exists only here
case "error":
return `Failed: ${state.error}`;
}
}ลองเทียบกับทางเลือกที่เห็นบ่อยอย่าง { loading: boolean; data?: T; error?: string } shape แบบนั้นยอมให้มีค่าผสมที่เป็นไปไม่ได้ เช่น loading: true พร้อมมีทั้ง error และ data และทุกจุดที่ใช้ต้องเช็ก field optional เอง discriminated union ทำให้ state พวกนั้นเขียนออกมาไม่ได้เลย การเข้าถึง state.data โดยไม่เช็ก status ก่อนจะ compile ไม่ผ่าน
เพราะทุก case return และ union ถูกครอบคลุมครบ TypeScript จึงรู้ว่าฟังก์ชันคืน string เสมอ ถ้าเพิ่ม state ที่ห้าเข้าไป มันจะแจ้งว่าฟังก์ชันขาด return statement ตอนท้าย
keyof และ indexed access type
keyof T สร้าง union ของชื่อ property ใน T ส่วน indexed access type T[K] ใช้ดู type ของ property เหมือนที่ obj[key] ใช้ดึงค่า:
interface Course {
title: string;
credit: number;
}
type CourseKey = keyof Course; // "title" | "credit"
type Credit = Course["credit"]; // number
type CourseValue = Course[keyof Course]; // string | numberใช้คู่กันแล้วเขียนฟังก์ชันที่ type-safe กับทุก key ได้:
function getProperty<T, K extends keyof T>(obj: T, key: K): T[K] {
return obj[key];
}
const course: Course = { title: "TypeScript", credit: 3 };
const title = getProperty(course, "title"); // string
getProperty(course, "teacher");
// Error: '"teacher"' is not assignable to parameter of type 'keyof Course'K extends keyof T คือ generic constraint ซึ่งอธิบายละเอียดพร้อมเรื่อง inference ใน TypeScript generics
Indexed access ยังใช้กับ number เพื่อดึง type ของสมาชิกใน array หรือ tuple ได้ด้วย: (typeof items)[number]
Index signature (indexable type)
ถ้า object ถูกใช้เป็น dictionary ที่ไม่รู้ชื่อ key ล่วงหน้า ให้อธิบายด้วย index signature:
interface Scores {
[studentId: string]: number;
}
const scores: Scores = { s001: 88, s002: 92 };
const s3 = scores["s003"]; // typed as number, but undefined at runtime
type ScoreKeys = keyof Scores; // string | numberมีสองเรื่องที่ควรรู้ เรื่องแรก keyof ของ string index signature คือ string | number เพราะ JavaScript แปลง key ที่เป็นตัวเลขเป็น string ให้อยู่แล้ว เรื่องที่สอง TypeScript ถือว่าการ lookup สำเร็จเสมอ ให้เปิด noUncheckedIndexedAccess ใน tsconfig.json เพื่อให้ scores["s003"] ได้ type เป็น number | undefined ซึ่งบังคับให้จัดการกรณีไม่มี key และสำหรับงาน dictionary การใช้ Map<string, number> มักเหมาะกว่าอยู่แล้ว
Mapped type: แปลงทุก property
Mapped type วนผ่าน union ของ key ซึ่งมักเป็น keyof T แล้วสร้าง property ให้ทีละตัว ให้มองว่าเป็น loop for...in ในระดับ type:
type CourseReadonly = {
readonly [K in keyof Course]: Course[K];
};
// { readonly title: string; readonly credit: number }K in keyof Course จะวนไปที่ "title" แล้วต่อด้วย "credit" ส่วน Course[K] เก็บ type เดิมของแต่ละ property ไว้
เพิ่มและลบ modifier
Mapped type เพิ่มหรือลบ readonly และ ? ได้ เครื่องหมาย + คือเพิ่ม (ค่าเริ่มต้น) และ - คือลบ:
type CourseOptional = {
[K in keyof Course]?: Course[K];
};
type CourseRequired = {
[K in keyof CourseOptional]-?: CourseOptional[K]; // remove optional
};
type CourseMutable = {
-readonly [K in keyof CourseReadonly]: CourseReadonly[K]; // remove readonly
};
type CourseWithSemester = CourseReadonly & { semester: string };ข้อผิดพลาดที่เจอบ่อยคือเขียน Course แทน Course[K] เป็น type ของ property ผลคือทุก property กลายเป็น object Course ทั้งก้อน ให้ใช้ indexed access T[K] เพื่อคง type ของแต่ละ property ไว้
Mapped type แบบ generic
Mapped type จะใช้ซ้ำได้เมื่อทำให้เป็น generic:
type Nullable<T> = { [K in keyof T]: T[K] | null };
type FormErrors<T> = { [K in keyof T]?: string };
const errors: FormErrors<Course> = { credit: "Must be a positive number" };TypeScript มี mapped type แบบนี้ให้ในตัวหลายตัว Partial<T>, Required<T>, Readonly<T>, Record<K, V> และ Pick<T, K> ล้วนเป็น mapped type ดูรายการเต็มได้ใน คู่มือ TypeScript utility types mapped type ที่เขียนแบบ [K in keyof T] เรียกว่า homomorphic และจะคง modifier readonly กับ ? เดิมไว้ เว้นแต่คุณสั่งเปลี่ยน
Key remapping ด้วย as
ตั้งแต่ TypeScript 4.1 mapped type เปลี่ยนชื่อ key ได้ด้วย as เมื่อใช้คู่กับ template literal type ก็สร้างชื่อ property ใหม่ได้:
type Getters<T> = {
[K in keyof T as `get${Capitalize<string & K>}`]: () => T[K];
};
type CourseGetters = Getters<Course>;
// { getTitle: () => string; getCredit: () => number }ต้องมี string & K เพราะ keyof T อาจมี key ที่เป็น number หรือ symbol ปนอยู่ และ Capitalize รับได้แค่ string
ถ้า remap key ไปเป็น never key นั้นจะถูกตัดทิ้ง จึงใช้กรอง property ตาม type ได้:
type OmitMethods<T> = {
[K in keyof T as T[K] extends (...args: never[]) => unknown ? never : K]: T[K];
};
class Cart {
items: string[] = [];
total = 0;
add(item: string) {
this.items.push(item);
}
}
type CartData = OmitMethods<Cart>; // { items: string[]; total: number }ส่วน T[K] extends ... ? never : K คือ conditional type ซึ่งอธิบายต่อในบทความเรื่อง generics
Template literal type
Template literal type ใช้ไวยากรณ์ backtick แบบเดียวกับ template string ของ JavaScript แต่ผลลัพธ์เป็น type ถ้ามี union อยู่ข้างใน จะได้ทุกชุดค่าผสม:
type Method = "GET" | "POST";
type Endpoint = `/api/${string}`;
type RouteKey = `${Method} ${Endpoint}`;
const ok: RouteKey = "GET /api/users"; // OK
const bad: RouteKey = "PATCH /api/users"; // Error
type Size = "sm" | "md" | "lg";
type Color = "primary" | "neutral";
type ButtonClass = `btn-${Color}-${Size}`; // 6 combinationsTypeScript มี helper สำหรับ string type ในตัวสี่ตัว คือ Uppercase, Lowercase, Capitalize และ Uncapitalize เช่น `on${Capitalize<"click" | "focus">}` จะได้ "onClick" | "onFocus"
อย่าให้ union ใหญ่เกินไป union สามชุดที่มีชุดละ 20 สมาชิกก็ได้ชุดค่าผสมถึง 8,000 แบบแล้ว
รวมทุกอย่างเข้าด้วยกัน: event map ที่มี type
ตัวอย่างนี้คือระบบ event ขนาดเล็กที่นิยาม type ครั้งเดียว แล้วได้ทั้งชื่อ event, type ของ payload และชื่อ handler:
type Events = {
userCreated: { id: string; email: string };
orderPaid: { orderId: string; amount: number };
};
const handlers: { [K in keyof Events]: Array<(payload: Events[K]) => void> } = {
userCreated: [],
orderPaid: [],
};
function on<K extends keyof Events>(name: K, handler: (payload: Events[K]) => void): void {
handlers[name].push(handler);
}
function emit<K extends keyof Events>(name: K, payload: Events[K]): void {
for (const handler of handlers[name]) handler(payload);
}
on("orderPaid", (p) => console.log(p.amount.toFixed(2))); // p is typed
emit("orderPaid", { orderId: "o-1", amount: 250 }); // OK
emit("orderPaid", { orderId: "o-1" }); // Error: amount is missing
emit("orderShipped", {}); // Error: unknown event
// Props for a component that accepts optional callbacks:
type Listeners<E> = {
[K in keyof E as `on${Capitalize<string & K>}`]?: (payload: E[K]) => void;
};
type AppListeners = Listeners<Events>; // { onUserCreated?: ...; onOrderPaid?: ... }เมื่อเพิ่ม event ใหม่ใน Events ทั้ง handler registry, emit, on และ props ของ listener จะอัปเดตตาม และ compiler จะชี้ทุกจุดที่ต้องรองรับ event นั้น
คำถามที่พบบ่อย
Union กับ intersection type ต่างกันอย่างไร?
Union A | B คือเป็น type ใดก็ได้ จึงใช้ได้แค่ส่วนที่ทั้งสองมีร่วมกันจนกว่าจะ narrow ส่วน intersection A & B คือเป็นทั้งสอง type พร้อมกัน จึงมีทุก property ของทั้งคู่
Mapped type ต่างจาก Record อย่างไร?
Record<K, V> ก็คือ mapped type แบบหนึ่ง: { [P in K]: V } ซึ่งให้ทุก key มี type ของค่าเหมือนกัน ส่วน mapped type ที่วนบน keyof T เองจะคงหรือแปลง type ของแต่ละ property แยกกันได้
-? ใน TypeScript หมายถึงอะไร?
คือการลบ modifier optional ใน mapped type ทำให้ทุก property เป็น required ส่วน -readonly ใช้ลบ readonly แบบเดียวกัน Required<T> ที่มีในตัวก็ใช้ -?
ทำไม keyof คืนค่า string | number?
เมื่อ type มี string index signature JavaScript จะแปลง key ตัวเลขเป็น string ให้ key ทั้งสองแบบจึงใช้ได้ ถ้าต้องการเฉพาะ key ที่เป็น string ให้ใช้ Extract<keyof T, string> หรือ string & keyof T
ควรใช้ discriminated union แทน field optional เมื่อไร?
ใช้เมื่อบาง field มีความหมายเฉพาะใน state บางแบบ เช่น data ตอนสำเร็จ และ error ตอนล้มเหลว มันกันค่าผสมที่เป็นไปไม่ได้ และให้ compiler ตรวจว่าจัดการครบทุก state
Checklist สำหรับ advanced type
- จำลอง state ด้วย discriminated union แทน boolean flag กับ field optional
- สร้าง type ต่อยอดด้วย
keyof,T[K]และ mapped type แทนการก๊อปนิยาม - ใช้
T[K]ไม่ใช่Tเป็น type ของ property ใน mapped type - ลองใช้ utility type ในตัวก่อนเขียน mapped type เอง
- ใช้ key remapping ด้วย
asเพื่อเปลี่ยนชื่อหรือกรอง property - ทำ union ใน template literal ให้เล็ก
Type ที่สร้างจากแหล่งความจริงเดียวแก้ไขได้ปลอดภัยกว่า และแนวคิดนี้ใช้ได้ไกลกว่าแค่ TypeScript ถ้าทีมของคุณต้องการคนช่วยออกแบบ frontend หรือ API layer ที่มี type ครบ Vectorkub พัฒนา web application ด้วย TypeScript ตั้งแต่ต้นจนจบ ส่วนการเลือกวิธีประกาศ object type อ่านต่อได้ที่ interface vs type alias
