Home / Fundamentals / Tự viết lại Pick, Omit, Partial: hiểu utility types từ gốc
Fundamentals

Tự viết lại Pick, Omit, Partial: hiểu utility types từ gốc

Tự viết lại Pick, Omit, Partial và utility type built-in bằng mapped type, conditional type và infer trong TypeScript

Bạn dùng Pick, Omit, Partial mỗi ngày nhưng chúng vẫn là hộp đen — nên khi cần một biến thể như DeepPartial hay “omit theo value type”, bạn bí, đành cast as any. Sự thật đơn giản hơn nhiều: mỗi utility type built-in chỉ là một–hai dòng mapped type hoặc conditional type. Tự viết lại chúng một lần là mở khóa cả tầng type-level programming của TypeScript — thứ giúp bạn tự chế ra đúng type mình cần.

Bài này không dạy cách dùng utility type trong React — phần đó nằm ở bài TypeScript Types Nâng Cao Cho React. Ở đây ta mổ xẻ cách chúng được cài đặt, để bạn viết được cả những type mà built-in không có.

1. Nguyên liệu type-level: keyof, T[K], và in

Trước khi lắp ráp, phải quen ba mảnh ghép. Mọi utility type trong bài đều chỉ là tổ hợp của chúng.

1.1. keyof — lấy tập key thành union

keyof T trả về union của mọi key trong T, dưới dạng string literal type:

1
2
3
4
5
6
7
8
type User = {
  id: number;
  name: string;
  email: string;
};

type UserKey = keyof User;
// → "id" | "name" | "email"

1.2. Indexed access T[K] — lấy type của value

T[K] đọc ra type của value tại key K — giống truy cập property, nhưng ở tầng type:

1
2
3
4
type User = { id: number; name: string };

type IdType = User["id"];          // number
type Values = User[keyof User];    // number | string  (union mọi value type)

1.3. Mapped type với in — duyệt qua từng key

[K in Keys] duyệt qua từng member của một union key và sinh ra property tương ứng — đây là vòng lặp for của type system:

1
2
3
4
type Flags = {
  [K in "darkMode" | "beta"]: boolean;
};
// → { darkMode: boolean; beta: boolean }

Gộp cả ba lại — keyof lấy key, in duyệt qua, T[K] lấy value type — là bạn đã có bộ khung của Partial, Pick, Readonly.

2. Viết lại nhóm mapped type: Partial, Required, Readonly, Pick

Nhóm này chỉ khác built-in ở modifier dán vào mỗi key: ? (optional), readonly, và hai dạng gỡ bỏ -? / -readonly.

2.1. MyPartial và modifier ?

Thêm ? sau key là mọi field thành optional:

1
2
3
4
5
6
7
type MyPartial<T> = {
  [K in keyof T]?: T[K];
};

type User = { id: number; name: string };
type PartialUser = MyPartial<User>;
// → { id?: number; name?: string }

Dùng thật khi viết hàm update: payload chỉ mang vài field cần đổi, không bắt truyền cả object.

2.2. MyRequired với -? và MyReadonly

-? làm điều ngược lại: gỡ ? khỏi mọi field, kể cả field vốn optional. readonly thì khóa field lại:

1
2
3
4
5
6
7
8
9
10
11
type MyRequired<T> = {
  [K in keyof T]-?: T[K];
};

type MyReadonly<T> = {
  readonly [K in keyof T]: T[K];
};

type Draft = { id?: number; title?: string };
type Complete = MyRequired<Draft>;   // { id: number; title: string }
type Frozen = MyReadonly<Draft>;     // { readonly id?: number; readonly title?: string }

2.3. Mutable — thứ built-in không có (-readonly)

Đối xứng với MyReadonly, -readonly gỡ bỏ readonly. TypeScript không có utility built-in cho việc này — đây là ví dụ đầu tiên cho thấy tự viết đáng giá:

1
2
3
4
5
6
7
8
9
10
type Mutable<T> = {
  -readonly [K in keyof T]: T[K];
};

type Config = {
  readonly host: string;
  readonly port: number;
};
type EditableConfig = Mutable<Config>;
// → { host: string; port: number }  ← đã bỏ readonly

2.4. MyPick và MyRecord

MyPick chỉ duyệt qua tập key K bạn chọn thay vì keyof T. Ràng buộc K extends keyof T đảm bảo bạn không pick nhầm key không tồn tại:

1
2
3
4
5
6
7
type MyPick<T, K extends keyof T> = {
  [P in K]: T[P];
};

type User = { id: number; name: string; password: string };
type PublicUser = MyPick<User, "id" | "name">;
// → { id: number; name: string }

MyRecord sinh ra object với tập key K cho trước, value cùng một type V. K extends PropertyKey (tức string | number | symbol) vì đó là mọi type key hợp lệ của object:

1
2
3
4
5
6
type MyRecord<K extends PropertyKey, V> = {
  [P in K]: V;
};

type RolePermissions = MyRecord<"admin" | "guest", boolean>;
// → { admin: boolean; guest: boolean }

Lưu ý: utility type chỉ tồn tại lúc compile

Pick không thật sự “cắt” field ra khỏi object lúc runtime — type bị xóa sạch khi compile, object gốc vẫn nguyên vẹn. Chạy thử để tự thấy:

1
2
3
4
5
6
7
8
9
type User = { id: number; name: string; password: string };
type PublicUser = Pick<User, "id" | "name">;

const user = { id: 1, name: "An", password: "secret" };
const publicUser: PublicUser = user; // gán object rộng hơn vào — hợp lệ

console.log(publicUser);
// → { id: 1, name: "An", password: "secret" }  ← password vẫn còn!
console.log("password" in publicUser); // → true

publicUseruser là cùng một object trong bộ nhớ. Muốn thật sự loại password khỏi dữ liệu gửi đi, bạn phải viết code runtime (destructuring, delete, hoặc pick thủ công) — Pick chỉ thu hẹp type, không đụng tới value.

3. Conditional types và distributive behavior

Mapped type biến đổi object. Còn để lọc union, ta cần conditional type: T extends U ? X : Y. Đây là nền của Exclude, Extract, NonNullable.

3.1. MyExclude, MyExtract — và vì sao distribution làm chúng chạy

1
2
3
4
5
6
type MyExclude<T, U> = T extends U ? never : T;
type MyExtract<T, U> = T extends U ? T : never;

type Roles = "admin" | "editor" | "viewer";
type Editors = MyExclude<Roles, "admin">;  // "editor" | "viewer"
type OnlyAdmin = MyExtract<Roles, "admin">; // "admin"

Chi tiết quan trọng: khi T là union, conditional type distribute — nó chạy trên từng member rồi gộp kết quả lại, chứ không xét cả union một lần:

1
2
3
4
5
6
type Editors = MyExclude<"admin" | "editor" | "viewer", "admin">;
// distribute thành:
//   ("admin"  extends "admin" ? never : "admin")  → never
// | ("editor" extends "admin" ? never : "editor") → "editor"
// | ("viewer" extends "admin" ? never : "viewer") → "viewer"
// → never | "editor" | "viewer"  =  "editor" | "viewer"

never bị loại khỏi union, nên những member khớp U biến mất — đó chính là cơ chế của Exclude.

3.2. Tắt distribution với [T] extends [U]

Đôi khi bạn không muốn distribute — muốn xét cả union như một khối. Bọc cả hai vế trong tuple [T] sẽ tắt hành vi này:

1
2
3
4
5
6
7
// Distribute BẬT: xét từng member
type IsString<T> = T extends string ? "yes" : "no";
type A = IsString<string | number>;   // "yes" | "no"

// Distribute TẮT: xét cả union
type IsStringStrict<T> = [T] extends [string] ? "yes" : "no";
type B = IsStringStrict<string | number>;  // "no"

Nhớ trick này — nó cứu bạn mỗi khi một conditional type “trả về union lạ” ngoài ý muốn.

3.3. MyNonNullable

Loại nullundefined khỏi union là một conditional type distributive kinh điển:

1
2
3
4
type MyNonNullable<T> = T extends null | undefined ? never : T;

type Raw = string | null | undefined;
type Clean = MyNonNullable<Raw>;  // string

Lưu ý: built-in đã đổi cách viết

Từ TypeScript 4.8, NonNullable<T> built-in được viết lại thành T & {} thay vì conditional type như trên. Lý do: T & {} giữ được hint “đây không phải literal” trong vài ngữ cảnh inference. Hai bản cho ra kết quả gán được cho nhau (mình đã kiểm tra hai chiều bằng tsc); bản conditional ở trên dễ hiểu hơn để học, nên cứ dùng nó khi mục tiêu là nắm cơ chế.

4. Lắp ráp: tự viết Omit, PartialBy, RequiredBy

Đây là điểm nhấn của bài: utility type compose với nhau. Omit không có cơ chế riêng nào cả — nó chỉ là Pick chồng lên Exclude.

4.1. MyOmit = Pick + Exclude

1
2
3
4
5
6
type MyOmit<T, K extends keyof any> = MyPick<T, MyExclude<keyof T, K>>;

type User = { id: number; name: string; password: string };
type SafeUser = MyOmit<User, "password">;
// MyExclude<"id"|"name"|"password", "password">  →  "id" | "name"
// MyPick<User, "id" | "name">  →  { id: number; name: string }

Định nghĩa này đúng từng ký tự với built-in trong lib.es5.d.ts: type Omit<T, K extends keyof any> = Pick<T, Exclude<keyof T, K>>. Hiểu được dòng này là bạn đã hiểu triết lý của cả bộ utility type: những cái nhỏ ghép thành cái lớn.

4.2. PartialBy và RequiredBy — compose thực dụng

Built-in Partial biến toàn bộ field thành optional. Nhưng thực tế bạn thường chỉ muốn vài field optional — ví dụ payload tạo mới: id do server sinh nên optional, phần còn lại vẫn bắt buộc. Ghép Omit với Partial là ra:

1
2
3
4
5
6
7
8
type PartialBy<T, K extends keyof T> = Omit<T, K> & Partial<Pick<T, K>>;
type RequiredBy<T, K extends keyof T> = Omit<T, K> & Required<Pick<T, K>>;

type Post = { id: number; title: string; body: string };

// Form tạo mới: id optional, còn lại bắt buộc
type CreatePost = PartialBy<Post, "id">;
const draft: CreatePost = { title: "Hello", body: "..." };  // hợp lệ, không cần id

Ý tưởng: Omit<T, K> giữ nguyên các field ngoài K, còn Partial<Pick<T, K>> lấy riêng nhóm K ra và làm chúng optional, rồi intersection & hàn hai mảnh lại. RequiredBy cũng vậy, chỉ đổi Partial thành Required để ép vài field vốn optional thành bắt buộc.

5. infer: rút type ra từ function và class

infer khai báo một biến type ngay trong mệnh đề extends, để bắt lấy phần bạn quan tâm. Nó mở ra cả nhóm utility type “đọc ngược” một signature.

5.1. MyReturnType, MyParameters, MyAwaited

1
2
3
4
5
6
7
8
9
10
type MyReturnType<T extends (...args: any) => any> =
  T extends (...args: any) => infer R ? R : any;

type MyParameters<T extends (...args: any) => any> =
  T extends (...args: infer P) => any ? P : never;

type Fn = (id: number, name: string) => boolean;

type Ret = MyReturnType<Fn>;   // boolean
type Args = MyParameters<Fn>;  // [id: number, name: string]

infer R nghĩa là “khớp một function bất kỳ, và gọi return type của nó là R“. MyParameters làm y hệt nhưng bắt lấy tuple tham số. Bản đơn giản của Awaited cũng chỉ là một infer:

1
2
3
type MyAwaited<T> = T extends Promise<infer V> ? V : T;

type Result = MyAwaited<Promise<string>>;  // string

Awaited built-in phức tạp hơn: nó unwrap thenable đệ quy, nên Awaited<Promise<Promise<number>>> ra number chứ không phải Promise<number>. Bản trên bóc đúng một tầng — đủ để hiểu ý tưởng.

5.2. MyConstructorParameters, MyInstanceType — factory/DI

Với class, ta khớp construct signature new (...args) => T thay vì call signature:

1
2
3
4
5
6
7
8
9
10
11
12
class ApiClient {
  constructor(public baseUrl: string, public timeout: number) {}
}

type MyConstructorParameters<T extends abstract new (...args: any) => any> =
  T extends abstract new (...args: infer P) => any ? P : never;

type MyInstanceType<T extends abstract new (...args: any) => any> =
  T extends abstract new (...args: any) => infer R ? R : any;

type CtorArgs = MyConstructorParameters<typeof ApiClient>; // [baseUrl: string, timeout: number]
type Client = MyInstanceType<typeof ApiClient>;            // ApiClient

Từ khóa abstract trong abstract new giúp type khớp cả abstract class. Đây là nền của một factory type-safe — hàm nhận class và đúng bộ tham số constructor của nó, thứ hay gặp trong DI container:

1
2
3
4
5
6
7
8
function create<T extends new (...args: any) => any>(
  Ctor: T,
  ...args: ConstructorParameters<T>
): InstanceType<T> {
  return new Ctor(...args);
}

const client = create(ApiClient, "https://api.dev", 5000); // client: ApiClient

Cùng họ infer-từ-function còn có ThisParameterType (lấy type của this) và OmitThisParameter (bỏ tham số this), dùng cho hàm khai báo this tường minh — hiếm gặp hơn nên bạn chỉ cần biết chúng tồn tại và cũng chỉ là một infer.

6. String types và ranh giới “intrinsic”

Không phải utility type nào cũng viết lại được. Nhóm string là nơi bạn chạm vào giới hạn của userland.

6.1. Template literal types

Template literal type ghép string literal ở tầng type, cú pháp giống template string:

1
2
3
4
5
type Method = "get" | "post";
type Entity = "user" | "order";

type Endpoint = `${Method}_${Entity}`;
// → "get_user" | "get_order" | "post_user" | "post_order"

Union nhân với union — TypeScript sinh ra mọi tổ hợp. Rất hợp để mô tả route name, event name, hay CSS class có tiền tố.

6.2. Uppercase/Lowercase/Capitalize/Uncapitalize — không tự viết lại được

TypeScript có bốn utility biến đổi chữ hoa/thường của string literal: Uppercase, Lowercase, Capitalize, Uncapitalize. Nhưng mở lib.es5.d.ts ra, bạn thấy định nghĩa của chúng như thế này:

1
2
type Uppercase<S extends string> = intrinsic;
type Capitalize<S extends string> = intrinsic;

intrinsic nghĩa là compiler tự cài đặt bằng code C/TypeScript bên trong, không có công thức type-level nào ở userland tả lại được — không có cách nào duyệt từng ký tự của một string literal type để viết hoa nó. Đây là ranh giới của bài “tự viết lại”: bạn dùng được chúng, nhưng không thể tái tạo. Cùng dạng intrinsic còn có NoInfer (mục 7.3).

7. Key remapping với as: Getters, OmitByValue, và NoInfer

Từ TypeScript 4.1, mapped type cho phép đổi tên key bằng mệnh đề as: [K in keyof T as NewKey]. Đây là công cụ mạnh nhất trong bài — nó vừa đổi tên, vừa lọc key.

7.1. Getters — nối Capitalize vào key remapping

Ghép as với Capitalize (vừa học ở mục 6) để sinh ra interface getter từ một object type:

1
2
3
4
5
6
7
type Getters<T> = {
  [K in keyof T as `get${Capitalize<string & K>}`]: () => T[K];
};

type User = { name: string; age: number };
type UserGetters = Getters<User>;
// → { getName: () => string; getAge: () => number }

string & K là mẹo thu hẹp K (vốn là string | number | symbol) về đúng string để Capitalize nhận vào — vì template literal chỉ ghép được string.

7.2. OmitByValue — lọc theo value type

Khi as cho ra never, key đó bị loại khỏi kết quả. Đây là cách lọc field theo value type — thứ built-in hoàn toàn không có:

1
2
3
4
5
6
7
8
9
10
11
12
13
type OmitByValue<T, V> = {
  [K in keyof T as T[K] extends V ? never : K]: T[K];
};

type Row = {
  id: number;
  name: string;
  onClick: () => void;
  onHover: () => void;
};

type DataOnly = OmitByValue<Row, Function>;
// → { id: number; name: string }  ← mọi field có value là function bị lọc bỏ

Omit built-in loại field theo tên; OmitByValue loại theo type của value. Cùng một kỹ thuật as ... ? never : K, nhưng giải quyết bài toán built-in bỏ ngỏ — đây là lý do rõ nhất để học type-level programming.

7.3. NoInfer — chặn suy luận ở đúng vị trí

NoInfer<T> (TypeScript 5.4, cũng intrinsic) bảo compiler: “đừng dùng vị trí này để suy ra T“. Ví dụ kinh điển là generic có default value:

1
2
3
4
5
6
7
8
9
10
11
function createStreetLight<C extends string>(
  colors: C[],
  defaultColor?: NoInfer<C>,
) {
  // ...
}

createStreetLight(["red", "yellow", "green"], "red");   // ok
createStreetLight(["red", "yellow", "green"], "blue");
// Error: Argument of type '"blue"' is not assignable to parameter
// of type '"red" | "yellow" | "green" | undefined'.

Không có NoInfer, tham số defaultColor: "blue" sẽ góp phần suy ra C, khiến C nở thành "red" | "yellow" | "green" | "blue" — và lỗi biến mất một cách âm thầm. NoInfer khóa việc suy luận C chỉ dựa vào colors, để "blue" bị bắt lỗi đúng như mong đợi.

8. Recursive: DeepPartial và chi phí compile

Mapped type có thể gọi đệ quy chính nó để đi sâu vào object lồng nhau. Partial built-in chỉ optional tầng trên cùng; DeepPartial optional mọi tầng:

1
2
3
4
5
6
7
8
9
10
11
type DeepPartial<T> = {
  [K in keyof T]?: T[K] extends object ? DeepPartial<T[K]> : T[K];
};

type Config = {
  server: { host: string; port: number };
  debug: boolean;
};

const patch: DeepPartial<Config> = { server: { port: 8080 } };
// hợp lệ — host cũng optional, và mọi tầng con đều optional

Cùng kỹ thuật, đổi ? thành readonly là ra DeepReadonly. Nhưng đệ quy type-level không miễn phí: type càng sâu, tsc càng tốn thời gian resolve, và với object rất lớn bạn có thể chạm giới hạn đệ quy của compiler. Nếu build của bạn bắt đầu chậm vì những type dạng này, xem bài TypeScript compile chậm? Tối ưu tsc và type-level code để biết cách đo và cắt chi phí.

9. Khi nào ĐỪNG tự viết

Viết lại để hiểu, không phải để ship. Trong dự án thật:

  • Built-in phủ phần lớn nhu cầuPick, Omit, Partial, Record, ReturnType… đã được compiler tối ưu và test kỹ. Đừng thay chúng bằng bản tự chế.
  • Cần biến thể phức tạp thì dùng thư viện. type-fest có sẵn PartialDeep, Merge, SetOptional, RequireAtLeastOne… — đã xử lý hàng loạt edge case mà bản viết vội của bạn sẽ bỏ sót.
  • Chỉ tự viết khi built-in và thư viện đều không có, như OmitByValue hay Mutable ở trên — và khi đó, bạn đã đủ nền để viết đúng.

Muốn luyện tập một cách bài bản, type-challenges là sân tập tốt nhất: nó bắt bạn cài lại đúng những utility type này (và khó hơn) rồi chấm bằng test type-level.

Nắm được ba mảnh ghép — mapped type, conditional type, và infer — là bạn đọc được source của gần như mọi utility type trong lib.es5.d.ts, và tự viết được cái built-in còn thiếu. Lần tới khi bí một type, đừng cast as any nữa: thử ghép chúng lại. Bắt đầu từ tier easy của type-challenges (Pick, Readonly, Exclude) — bạn sẽ giải hết trong một buổi tối, vì giờ bạn đã biết chúng thật ra là gì.

← Previous
Promise.all, race, any, allSettled: chọn đúng combinator
Table of Contents
  1. 1. Nguyên liệu type-level: keyof, T[K], và in
    1. 1.1. keyof — lấy tập key thành union
    2. 1.2. Indexed access T[K] — lấy type của value
    3. 1.3. Mapped type với in — duyệt qua từng key
  2. 2. Viết lại nhóm mapped type: Partial, Required, Readonly, Pick
    1. 2.1. MyPartial và modifier ?
    2. 2.2. MyRequired với -? và MyReadonly
    3. 2.3. Mutable — thứ built-in không có (-readonly)
    4. 2.4. MyPick và MyRecord
  3. 3. Conditional types và distributive behavior
    1. 3.1. MyExclude, MyExtract — và vì sao distribution làm chúng chạy
    2. 3.2. Tắt distribution với [T] extends [U]
    3. 3.3. MyNonNullable
  4. 4. Lắp ráp: tự viết Omit, PartialBy, RequiredBy
    1. 4.1. MyOmit = Pick + Exclude
    2. 4.2. PartialBy và RequiredBy — compose thực dụng
  5. 5. infer: rút type ra từ function và class
    1. 5.1. MyReturnType, MyParameters, MyAwaited
    2. 5.2. MyConstructorParameters, MyInstanceType — factory/DI
  6. 6. String types và ranh giới “intrinsic”
    1. 6.1. Template literal types
    2. 6.2. Uppercase/Lowercase/Capitalize/Uncapitalize — không tự viết lại được
  7. 7. Key remapping với as: Getters, OmitByValue, và NoInfer
    1. 7.1. Getters — nối Capitalize vào key remapping
    2. 7.2. OmitByValue — lọc theo value type
    3. 7.3. NoInfer — chặn suy luận ở đúng vị trí
  8. 8. Recursive: DeepPartial và chi phí compile
  9. 9. Khi nào ĐỪNG tự viết