กลับไปยังบทความ

Frontend

TypeScript Utility Types: ใช้ Partial, Pick, Omit, Record และเขียน Utility Type เอง

คู่มือ TypeScript utility types ฉบับใช้งานจริง ครอบคลุม Partial, Required, Readonly, Record, Pick, Omit, Exclude, Extract, NonNullable, ReturnType, Parameters และ string types พร้อมวิธีเขียน utility type ของตัวเอง

24 กันยายน 256910 นาทีในการอ่าน

typescript utility types guide

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 ของ functionPromise<User>
Parameters<F>parameter ของ function ในรูป tuple[id: number]
Uppercase<S> และตัวอื่นในกลุ่มแปลง string literal type"GET"

ตัวอย่างทั้งหมดด้านล่างใช้ model เดียวกัน:

ts
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 ที่เปลี่ยน:

ts
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 บรรทัดเดียว:

ts
type Partial<T> = { [P in keyof T]?: T[P] };

Partial ทำงานแค่ชั้นบนสุด object ที่ซ้อนอยู่ข้างในยังคง property แบบ required ไว้เหมือนเดิม

Required และ modifier -?

Required<T> ทำตรงกันข้าม คือเอา ? ออกจากทุก property ใช้ได้ดีกับ config: รับ config บางส่วนจากผู้เรียก รวมกับค่า default แล้วคืน config ที่ครบถ้วน

ts
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 ได้ด้วย ? หรือ +? และเอาออกได้ด้วย -?:

ts
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 ค่าใหม่:

ts
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:

ts
type Mutable<T> = { -readonly [K in keyof T]: T[K] };

Record: dictionary และ lookup table ที่มี type

Record<K, V> สร้าง object type ที่มี key เป็น K และ value ทุกตัวเป็น V เราเขียนเองได้แบบนี้:

ts
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 นั้น:

ts
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 ที่ระบุออก:

ts
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 มีสองข้อ:

  1. มันไม่ตรวจชื่อ key Pick บังคับ K extends keyof T แต่ Omit รับ string อะไรก็ได้ Omit<User, "emial"> จึง compile ผ่านและไม่ได้ตัดอะไรออกเลย ถ้ากังวลเรื่องนี้ ให้สร้างเวอร์ชันที่เข้มงวดกว่า:

    ts
    type StrictOmit<T, K extends keyof T> = Omit<T, K>;
  2. มันไม่กระจายตัวบน union Omit<A | B, "id"> จะเหลือแค่ property ที่ A และ B มีร่วมกัน ถ้าใช้กับ discriminated union ให้ใช้เวอร์ชัน distributive:

    ts
    type DistributiveOmit<T, K extends PropertyKey> =
      T extends unknown ? Omit<T, K> : never;

Exclude, Extract และ NonNullable: กรอง union

สามตัวนี้ทำงานกับ union type ไม่ใช่ object type:

ts
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"]>;  // Date

definition ของมันคือ distributive conditional type:

ts
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 ได้ด้วย:

ts
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 ออกมา

ts
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:

ts
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 แบบไม่แก้ไข:

ts
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:

ts
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:

ts
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 สองตัวที่เจอในโปรเจกต์จริงบ่อย:

ts
// 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 เดียว:

ts
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 และช่วยได้

พร้อมสร้างสิ่งที่ยิ่งใหญ่หรือยัง?

เล่าโปรเจคของคุณให้เราฟัง — เราจะพาคุณไปถึง

เลือกคุกกี้ที่เราใช้ได้ คุกกี้ที่จำเป็นช่วยให้เว็บไซต์ทำงานได้และเปิดใช้งานเสมอ