TypeScript utility types คือ generic type ที่มากับภาษา ใช้แปลง type หนึ่งไปเป็นอีก type หนึ่ง แทนที่จะเขียน interface UserUpdate ขึ้นมาใหม่ด้วยมือ เราเขียน Partial<User> แทนที่จะคัดลอก return type ของ function ไปตั้งเป็น alias ใหม่ เราเขียน ReturnType<typeof fn> เมื่อ type ต้นทางเปลี่ยน type ที่ derive มาจะเปลี่ยนตามทันที type ในโปรเจกต์จึงไม่หลุดออกจากกัน
บทความนี้รวม utility type ที่ใช้บ่อยที่สุด จัดกลุ่มตามหน้าที่ และดูว่าแต่ละตัวถูกเขียนขึ้นมาอย่างไร ทั้งหมดสร้างจาก mapped type และ conditional type เมื่ออ่าน definition ของมันออก เราก็เขียน utility type ของตัวเองได้
ตารางสรุป TypeScript utility types
| Utility type | ทำอะไร | ตัวอย่างผลลัพธ์ |
|---|---|---|
Partial<T> | ทำให้ทุก property เป็น optional | { name?: string } |
Required<T> | ทำให้ทุก property เป็น required | { name: string } |
Readonly<T> | ทำให้ทุก property เป็น readonly | { readonly name: string } |
Record<K, V> | object type ที่มี key เป็น K และ value เป็น V | { a: number; b: number } |
Pick<T, K> | เก็บไว้เฉพาะ key K | { id: number } |
Omit<T, K> | ตัด key K ออก | { name: string } |
Exclude<U, E> | ตัดสมาชิกของ union ที่ assign ให้ E ได้ออก | "a" | "b" |
Extract<U, E> | เก็บเฉพาะสมาชิกของ union ที่ assign ให้ E ได้ | "c" |
NonNullable<T> | ตัด null และ undefined ออก | string |
ReturnType<F> | return type ของ function | Promise<User> |
Parameters<F> | parameter ของ function ในรูป tuple | [id: number] |
Uppercase<S> และตัวอื่นในกลุ่ม | แปลง string literal type | "GET" |
ตัวอย่างทั้งหมดด้านล่างใช้ model เดียวกัน:
interface User {
id: number;
name: string;
email: string;
role: "admin" | "member" | "guest";
deletedAt: Date | null;
}Partial, Required และ Readonly: เปลี่ยน modifier ของ property
Partial สำหรับการอัปเดตข้อมูล
Partial<T> ทำให้ทุก property เป็น optional เหมาะกับ PATCH endpoint หรือ function อัปเดตที่ผู้เรียกส่งมาเฉพาะ field ที่เปลี่ยน:
function updateUser(id: number, changes: Partial<User>): Promise<User> {
return db.users.update(id, changes);
}
updateUser(1, { name: "Somsri" }); // OK
// updateUser(1, { nickname: "S" }); // Error: 'nickname' does not exist in type 'Partial<User>'definition ของมันเป็น mapped type บรรทัดเดียว:
type Partial<T> = { [P in keyof T]?: T[P] };Partial ทำงานแค่ชั้นบนสุด object ที่ซ้อนอยู่ข้างในยังคง property แบบ required ไว้เหมือนเดิม
Required และ modifier -?
Required<T> ทำตรงกันข้าม คือเอา ? ออกจากทุก property ใช้ได้ดีกับ config: รับ config บางส่วนจากผู้เรียก รวมกับค่า default แล้วคืน config ที่ครบถ้วน
type Options = { retries?: number; timeoutMs?: number };
const defaults: Required<Options> = { retries: 3, timeoutMs: 5000 };
function withDefaults(opts: Options): Required<Options> {
return { ...defaults, ...opts };
}ส่วนที่น่าสนใจคือวิธีเขียน mapped type เพิ่ม modifier ได้ด้วย ? หรือ +? และเอาออกได้ด้วย -?:
type Person = { name?: string; age?: number; address?: string };
type MyRequired<T> = { [K in keyof T]-?: T[K] };
type MyRequiredPerson = MyRequired<Person>;
// { name: string; age: number; address: string }การเอา ? ออกจะเอา undefined ที่มากับความเป็น optional ออกไปด้วย name จึงกลายเป็น string ไม่ใช่ string | undefined
ข้อควรระวังของ withDefaults: ถ้า spread object ที่มี { retries: undefined } ค่า default จะถูกทับด้วย undefined และ compiler จะไม่เตือนจนกว่าจะเปิด exactOptionalPropertyTypes ซึ่งห้าม assign undefined ให้ optional property ที่ไม่ได้ระบุ undefined ไว้ใน type
Readonly และ -readonly
Readonly<T> ใส่ readonly ให้ทุก property compiler จะไม่ยอมให้ assign ค่าใหม่:
const admin: Readonly<User> = await getUser(1);
// admin.role = "guest"; // Error: Cannot assign to 'role' because it is a read-only property.มันมีผลแค่ตอน compile และทำงานแค่ชั้นบนสุด ถ้าต้องการป้องกันจริงตอน runtime ให้ใช้ Object.freeze ส่วนการกลับด้านเป็น type Mutable ใช้ modifier -readonly:
type Mutable<T> = { -readonly [K in keyof T]: T[K] };Record: dictionary และ lookup table ที่มี type
Record<K, V> สร้าง object type ที่มี key เป็น K และ value ทุกตัวเป็น V เราเขียนเองได้แบบนี้:
type MyRecord<T extends string | number | symbol, U> = { [K in T]: U };
type MyPerson = MyRecord<"name" | "address", string>;
// same as { name: string; address: string }
type MyPerson2 = Record<"name" | "address", string>;การใช้งานที่คุ้มที่สุดคือ lookup table ที่ใช้ union เป็น key ถ้าวันหนึ่งมีคนเพิ่ม role ใหม่ compiler จะชี้ทุก table ที่ยังไม่มี role นั้น:
const permissions: Record<User["role"], string[]> = {
admin: ["read", "write", "delete"],
member: ["read", "write"],
guest: ["read"],
};Record<string, V> ใช้เป็น dictionary ได้ แต่ TypeScript จะถือว่าทุก key มีอยู่จริง cache["missing"] มี type เป็น V ไม่ใช่ V | undefined จนกว่าจะเปิด noUncheckedIndexedAccess ถ้า key เป็นค่าที่เปลี่ยนไปเรื่อย ๆ ใช้ Map<string, V> มักอ่านง่ายกว่า
Pick และ Omit: เลือก property
Pick<T, K> เก็บ key ที่ระบุไว้ ส่วน Omit<T, K> ตัด key ที่ระบุออก:
type UserSummary = Pick<User, "id" | "name">;
// { id: number; name: string }
type NewUser = Omit<User, "id" | "deletedAt">;
// { name: string; email: string; role: "admin" | "member" | "guest" }pattern ที่เจอบ่อยคือ payload สำหรับสร้างข้อมูลที่ตัด field ที่ server สร้างเองออก และ public view ที่ pick มาเฉพาะ field ที่เปิดเผยได้
เรื่องที่ควรรู้เกี่ยวกับ Omit มีสองข้อ:
-
มันไม่ตรวจชื่อ key
PickบังคับK extends keyof Tแต่Omitรับ string อะไรก็ได้Omit<User, "emial">จึง compile ผ่านและไม่ได้ตัดอะไรออกเลย ถ้ากังวลเรื่องนี้ ให้สร้างเวอร์ชันที่เข้มงวดกว่า:tstype StrictOmit<T, K extends keyof T> = Omit<T, K>; -
มันไม่กระจายตัวบน union
Omit<A | B, "id">จะเหลือแค่ property ที่AและBมีร่วมกัน ถ้าใช้กับ discriminated union ให้ใช้เวอร์ชัน distributive:tstype DistributiveOmit<T, K extends PropertyKey> = T extends unknown ? Omit<T, K> : never;
Exclude, Extract และ NonNullable: กรอง union
สามตัวนี้ทำงานกับ union type ไม่ใช่ object type:
type Role = User["role"]; // "admin" | "member" | "guest"
type StaffRole = Exclude<Role, "guest">; // "admin" | "member"
type GuestRole = Extract<Role, "guest" | "bot">; // "guest"
type Deleted = NonNullable<User["deletedAt"]>; // Datedefinition ของมันคือ distributive conditional type:
type Exclude<T, U> = T extends U ? never : T;
type Extract<T, U> = T extends U ? T : never;
type NonNullable<T> = T & {};เงื่อนไขถูกเช็กทีละสมาชิกของ union Exclude จึงทิ้งสมาชิกที่ match ส่วน Extract เก็บไว้ ส่วน NonNullable ถูกนิยามเป็น T & {} ตั้งแต่ TypeScript 4.8 การ intersect กับ {} ตัด null และ undefined ออกโดยไม่แตะ type อื่น เรื่อง distribution อธิบายละเอียดไว้ใน TypeScript generics และ conditional types
Extract ใช้กับ discriminated union ได้ด้วย:
type UiEvent =
| { type: "click"; x: number; y: number }
| { type: "keypress"; key: string };
type ClickEvent = Extract<UiEvent, { type: "click" }>;ReturnType และ Parameters: ดึง type จาก function
บางครั้ง function คือ source of truth และเราอยากได้ type ที่เปลี่ยนตามมัน โดยเฉพาะ function จาก library ที่ไม่ได้ export type ออกมา
async function fetchUser(id: number, opts?: { signal?: AbortSignal }) {
const res = await fetch(`/api/users/${id}`, opts);
return (await res.json()) as User;
}
type FetchUserResult = ReturnType<typeof fetchUser>; // Promise<User>
type FetchedUser = Awaited<ReturnType<typeof fetchUser>>; // User
type FetchUserArgs = Parameters<typeof fetchUser>; // [id: number, opts?: { signal?: AbortSignal }]
type FetchUserOptions = Parameters<typeof fetchUser>[1]; // { signal?: AbortSignal } | undefinedสังเกตว่าต้องมี typeof เพราะ ReturnType รับ function type ไม่ใช่ตัว function ถ้าเป็น async function ให้ครอบด้วย Awaited เพื่อให้ได้ค่าที่ resolve แล้ว
ทั้งสองตัวเขียนด้วย infer:
type ReturnType<T extends (...args: any) => any> =
T extends (...args: any) => infer R ? R : any;
type Parameters<T extends (...args: any) => any> =
T extends (...args: infer P) => any ? P : never;Parameters สะดวกมากเวลาเขียน wrapper ที่ส่งต่อ argument แบบไม่แก้ไข:
function withLogging<F extends (...args: any[]) => any>(fn: F) {
return (...args: Parameters<F>): ReturnType<F> => {
console.log(`calling ${fn.name}`, args);
return fn(...args);
};
}Intrinsic string types: Uppercase, Lowercase, Capitalize, Uncapitalize
มี utility type สี่ตัวที่แปลง string literal type เรียกว่า intrinsic เพราะ compiler implement มันเองโดยตรง ไม่ได้เขียนเป็น type definition:
type Method = "get" | "post";
type A = Uppercase<Method>; // "GET" | "POST"
type B = Lowercase<"X-Request-ID">; // "x-request-id"
type C = Capitalize<"click">; // "Click"
type D = Uncapitalize<"UserId">; // "userId"มันมีประโยชน์ที่สุดเมื่อใช้คู่กับ template literal type และการ remap key ใน mapped type ตัวอย่างนี้สร้างชื่อ event handler จาก event map:
type Events = { click: MouseEvent; focus: FocusEvent };
type Handlers = {
[K in keyof Events as `on${Capitalize<K & string>}`]: (e: Events[K]) => void;
};
// { onClick: (e: MouseEvent) => void; onFocus: (e: FocusEvent) => void }clause as ใช้เปลี่ยนชื่อแต่ละ key อ่านเรื่อง mapped type และ key remapping เพิ่มได้ที่ TypeScript advanced types
เขียน utility type ของตัวเอง
เมื่อตัวที่มากับภาษาไม่พอดีกับงาน ให้เอามาประกอบกัน นี่คือ helper สองตัวที่เจอในโปรเจกต์จริงบ่อย:
// Make only some keys optional
type PartialBy<T, K extends keyof T> = Omit<T, K> & Partial<Pick<T, K>>;
type UserInput = PartialBy<User, "role" | "deletedAt">;
// Recursively optional, for nested config objects
type DeepPartial<T> = {
[K in keyof T]?: T[K] extends object ? DeepPartial<T[K]> : T[K];
};เวลา hover ดู UserInput ใน editor จะเห็นเป็น intersection ที่อ่านยาก เทคนิคที่นิยมคือ helper Simplify ที่รวมให้เป็น object type เดียว:
type Simplify<T> = { [K in keyof T]: T[K] } & {};
type UserInputFlat = Simplify<PartialBy<User, "role" | "deletedAt">>;เก็บ helper ที่เขียนเองไว้ในไฟล์เดียว ตั้งชื่อให้ชัด และใส่ comment สั้น ๆ พร้อมตัวอย่างผลลัพธ์ อีกเรื่องที่ควรรู้คือ DeepPartial แบบนี้จะ recurse เข้าไปใน array และ function ด้วย ซึ่งส่วนใหญ่ไม่เป็นปัญหาสำหรับ config object
คำถามที่พบบ่อย
Pick กับ Omit ต่างกันอย่างไร
Pick<T, K> เก็บไว้เฉพาะ key ที่ระบุ ส่วน Omit<T, K> เก็บทุกอย่างยกเว้น key ที่ระบุ ใช้ Pick เมื่อต้องการไม่กี่ field และใช้ Omit เมื่อต้องการเกือบทั้งหมด Pick ตรวจว่า key มีอยู่จริง แต่ Omit ไม่ตรวจ
Partial ใน TypeScript ทำงานแบบ deep ไหม
ไม่ Partial ทำให้ optional แค่ property ชั้นบนสุด ถ้าเป็น object ซ้อนกัน ให้เขียน DeepPartial แบบ recursive อย่างตัวอย่างด้านบน
จะเอา return type ของ async function ได้อย่างไร
ใช้ Awaited<ReturnType<typeof fn>> เพราะ ReturnType อย่างเดียวจะได้ Promise<T> แล้ว Awaited จะแกะออกมาเป็น T
ควรใช้ Record แทน index signature เมื่อไร
Record<"a" | "b", V> ที่ใช้ union เป็น key บังคับให้มีครบทุก key จึงเหมาะกับ lookup table ที่ต้องครบทุกกรณี ถ้าเป็น string key อะไรก็ได้ Record<string, V> กับ { [key: string]: V } มีความหมายเท่ากัน เลือกแบบที่ทีมอ่านง่ายกว่า
utility types ตรวจสอบตอน runtime ไหม
ไม่ เหมือน type อื่นทั้งหมดใน TypeScript มันถูกลบทิ้งตอน compile Readonly ไม่ได้กันการแก้ค่าตอน runtime และ Partial ไม่ได้ validate request body ให้ ต้องใช้ runtime validation คู่กันที่ขอบของระบบ
เช็กลิสต์สั้น ๆ
- derive type แทนการเขียนซ้ำ:
Partialสำหรับอัปเดต,Omitสำหรับ payload ตอนสร้าง,Pickสำหรับ public view - ใช้
Recordที่มี union เป็น key สำหรับ lookup table จะได้ไม่ลืมสมาชิกใหม่ - กรอง union ด้วย
Exclude,ExtractและNonNullable - ดึง type จาก function ด้วย
ReturnType,ParametersและAwaited - จำไว้ว่า
PartialและReadonlyทำงานแค่ชั้นบนสุด และOmitไม่ตรวจชื่อ key - เขียน helper เองด้วย mapped type และ modifier
-?กับ-readonly
type ที่ derive จากกันช่วยให้ codebase ขนาดใหญ่สอดคล้องกันแม้จะเปลี่ยนไปเรื่อย ๆ ถ้าคุณกำลังวางแผนโปรเจกต์ TypeScript และอยากได้คนช่วยออกแบบ type และ architecture Vectorkub รับพัฒนา web application และช่วยได้
