jvinhit//lab

Search posts

Type to search across journal entries.

navigate open esc close

TS Type Challenges · Part 2 — Mapped Types

Part 2: master the mapped type. Rebuild Partial, Required, Mutable, Readonly 2, PartialByKeys, Merge and ReplaceKeys; add and remove the readonly/? modifiers; and key-remap with as — each with a toggle-to-reveal answer.

Đây là Phần 2 của series 12 bài về TypeScript type challenges. Ở Phần 1 ta gặp năm khối nền tảng; ở đây ta luyện sâu khối đầu tiên: mapped type.

Mapped type lặp qua một union các key và dựng object từ mỗi key. Chỉ một ý tưởng đó — cộng hai modifier (readonly, ?) và đổi key (as) — đã tạo nên hầu hết utility type built-in của TypeScript.

Ghi nguồn: các đáp án theo cách giải chuẩn của cộng đồng type-challenges (giấy phép MIT); số khớp ID chính thức.


Hình dạng của mọi mapped type

Ghi nhớ khuôn mẫu này — mọi thứ bên dưới chỉ là biến thể của nó:

type Mapped<T> = {
  [K in keyof T]: T[K];
  // ▲ modifier  ▲ key source        ▲ value
  //   (readonly/?)  (a union of keys)   (usually indexed access)
};

Trình biên dịch thực sự làm gì

Khi TypeScript thấy [K in keyof T], nó không chạy vòng lặp runtime. Nó mở rộng mapped type lúc biên dịch: lấy union các key, duyệt từng thành viên, rồi lắp một object type mới. Hãy coi như for...of trên danh sách key ở tầng type.

interface Point { x: number; y: number }

// step 1: keyof Point → 'x' | 'y'
// step 2: loop K = 'x' → slot x: Point['x'] = number
// step 3: loop K = 'y' → slot y: Point['y'] = number
// step 4: assemble → { x: number; y: number }
type Copy<Point> = { [K in keyof Point]: Point[K] };

Ba núm bạn có thể vặn:

  1. Modifier trước key: thêm readonly/?, hoặc gỡ bằng dấu - đứng trước.
  2. Nguồn key: bất kỳ union nào của string | number | symbol — thường là keyof T, nhưng có thể là tập con.
  3. Value: type bất kỳ, thường là T[K] (indexed access), nhưng bạn có thể biến đổi.

Cạm bẫy: homomorphic mapped type

Khi nguồn key đúng là keyof T và value là T[K], TypeScript coi kết quả là homomorphic — nó giữ modifier readonly? từ bản gốc. Đổi nguồn key (vd. sang union tập con) hoặc biểu thức value, việc giữ modifier dừng lại — đó là lý do “làm X chỉ với một số key” thường cần công thức chia-rồi-giao.

Tóm tắt: { [K in Keys]: Value } là vòng lặp compile-time trên union key; modifier và as là cú pháp bổ sung.


2757 · PartialByKeys (và Partial thường)

Mục tiêu

Chỉ biến các key liệt kê trong K thành optional; giữ mọi key khác y hệt trên T. Khi bỏ qua K, hành xử như Partial built-in và làm mềm tất cả.

Vì sao không hiển nhiên: Modifier ? của mapped type áp cho mọi key trong vòng lặp — bạn không thể nói “chỉ thêm ? khi K khớp” trong một [K in keyof T]? duy nhất.

Khởi động trước — biến mọi thuộc tính thành optional:

type MyPartial<T> = { [K in keyof T]?: T[K] };

? thêm modifier optional cho mọi key.

interface User {
  name: string;
  age: number;
  address: string;
}
type R = PartialByKeys<User, 'name'>;
// Expected: { name?: string; age: number; address: string }

Cách thử ngây thơ (và vì sao hỏng)

// ❌ Makes ALL keys optional — the ? sits outside the conditional
type PartialByKeys<T, K extends keyof T> = {
  [P in keyof T]?: P extends K ? T[P] : T[P];
};

? gắn vào pattern key được map, không phải từng nhánh conditional ở vế value. TypeScript không diễn đạt được “chỉ optional cho một số key” trong một lượt homomorphic.

Hiện đáp án
type PartialByKeys<T, K extends keyof T = keyof T> = Omit<T, K> &
  Partial<Pick<T, K>> extends infer O
  ? { [P in keyof O]: O[P] }
  : never;

Cơ chế: chia → biến đổi → giao → làm đẹp

Thay vì ép một mapped type, chia T thành hai object type, chỉ biến đổi nửa cần thiết, rồi giao lại:

  • key không thuộc K, giữ nguyên.
  • key thuộc K, làm optional bằng mapped type thường.
  • gộp hai nửa thành một type.
  • làm đẹp: map lại intersection thành một object phẳng để hover gọn hơn.

Từng dòng

  1. ràng buộc K là key thật của T; mặc định mọi key để bỏ K ra được Partial đầy đủ.
  2. utility built-in bỏ các key trong K.
  3. Pick giữ chỉ K, Partial thêm ? cho mọi key trong tập con đó.
  4. giao: object phải thỏa cả hai nửa.
  5. sao chép key của O vào một object type (map đồng nhất làm phẳng hiển thị).

Đánh giá type từng bước

Input cụ thể: PartialByKeys<User, 'name'>:

interface User { name: string; age: number; address: string }

// step 1: K = 'name'
// step 2: Omit<User, 'name'>
//         → { age: number; address: string }
// step 3: Pick<User, 'name'>
//         → { name: string }
// step 4: Partial<Pick<User, 'name'>>
//         → { [P in 'name']?: string } → { name?: string }
// step 5: intersect
//         → { age: number; address: string } & { name?: string }
// step 6: prettify — loop P over keyof O
//         P = 'age'    → age: number
//         P = 'address'→ address: string
//         P = 'name'   → name?: string
// step 7: final R
//         → { age: number; address: string; name?: string }
type R = PartialByKeys<User, 'name'>;

Cạm bẫy

  • Giữ homomorphic: { [P in keyof T]?: T[P] } sao chép optional/readonly từ T; chia bằng Omit/Pick tránh điều đó khi cần modifier không đối xứng.
  • Hiển thị intersection: { a: 1 } & { b?: 2 } đúng nhưng xấu khi hover — prettify sửa hiển thị, không đổi ngữ nghĩa.
  • Mặc định K: K extends keyof T = keyof T từ chối kiểu PartialByKeys<User, 'email'> lúc biên dịch.

Tóm tắt: không bật ? theo từng key trong một map — chia bằng Omit/Pick, làm mềm một nửa, giao, làm đẹp.


2759 · RequiredByKeys (và Required thường)

Mục tiêu

Đối xứng PartialByKeys: chỉ biến key trong K thành bắt buộc; giữ optional của key khác.

Vì sao không hiển nhiên: -? (gỡ optional) áp cho mọi key trong vòng map, giống ? áp cho tất cả. Cần cùng mẹo chia-rồi-giao, đổi Partial thành Required.

Bản thường trước — gỡ optional khỏi tất cả:

type MyRequired<T> = { [K in keyof T]-?: T[K] };

Dấu -? đứng đầu gỡ modifier optional khỏi mỗi key.

type R = RequiredByKeys<{ name?: string; age?: number }, 'name'>;
// Expected: { name: string; age?: number }

Cách thử ngây thơ (và vì sao hỏng)

// ❌ Removes ? from EVERY key — age becomes required too
type RequiredByKeys<T, K extends keyof T> = {
  [P in keyof T]-?: P extends K ? T[P] : T[P];
};

Modifier -? không có điều kiện; nó kích hoạt cho mọi P trong vòng lặp.

Hiện đáp án
type RequiredByKeys<T, K extends keyof T = keyof T> = Omit<T, K> &
  Required<Pick<T, K>> extends infer O
  ? { [P in keyof O]: O[P] }
  : never;

Cơ chế

Khung y hệt PartialByKeys:

  • key ngoài K, giữ optional gốc.
  • { [P in keyof Pick<T,K>]-?: ... } gỡ ? chỉ từ key trong K.
  • & + prettify — giống trước.

-? là núm gỡ optional; Required chỉ là mapped type built-in bật nó cho mọi key nó thấy.

Đánh giá type từng bước

type Input = { name?: string; age?: number }

// step 1: K = 'name'
// step 2: Omit<Input, 'name'>
//         → { age?: number }
// step 3: Pick<Input, 'name'>
//         → { name?: string }
// step 4: Required<Pick<Input, 'name'>>
//         → { [P in 'name']-?: string } → { name: string }
// step 5: intersect
//         → { age?: number } & { name: string }
// step 6: prettify
//         → { name: string; age?: number }
type R = RequiredByKeys<Input, 'name'>;

Cạm bẫy

  • -? chỉ gỡ, không thêm key: nếu K đặt tên key không có trên T, Pick{} và không có gì mới.
  • Bảng modifier (bốn thao tác):
[K in keyof T]:     T[K]   // copy as-is
readonly [K ...]:   T[K]   // add readonly
-readonly [K ...]:  T[K]   // remove readonly
[K ...]?:           T[K]   // add optional
[K ...]-?:          T[K]   // remove optional

Tóm tắt: “chia → biến đổi một phần → giao → làm đẹp” là công thức tái dùng cho mọi bài “làm X chỉ với các key này”.


2793 · Mutable

Mục tiêu

Gỡ readonly khỏi mọi thuộc tính — ngược với Readonly<T> built-in.

type R = Mutable<{ readonly a: 1; readonly b: 2 }>;
// Expected: { a: 1; b: 2 }

Vì sao không hiển nhiên: readonlymodifier thuộc tính, không phải type bạn “bóc” bằng T[K]. Bản sao thường { [K in keyof T]: T[K] } là homomorphic và giữ readonly.

Cách thử ngây thơ (và vì sao hỏng)

// ❌ Homomorphic — readonly survives the copy
type Mutable<T> = { [K in keyof T]: T[K] };

type Bad = Mutable<{ readonly a: 1 }>;
// Still: { readonly a: 1 }

Phải dùng modifier mapped type -readonly để gỡ tính bất biến một cách tường minh.

Hiện đáp án
type Mutable<T> = { -readonly [K in keyof T]: T[K] };

Từng dòng

  1. lặp qua mọi key của T.
  2. -readonly trước pattern key — gỡ modifier readonly khỏi mỗi thuộc tính được map.
  3. T[K] — giữ nguyên type value (chỉ cờ mutability đổi).

Như -? gỡ optional, -readonly gỡ tính bất biến.

Đánh giá type từng bước

type Input = { readonly a: 1; readonly b: 2 }

// step 1: keyof Input → 'a' | 'b'
// step 2: K = 'a', apply -readonly → a: 1  (writable)
// step 3: K = 'b', apply -readonly → b: 2  (writable)
// step 4: assemble
//         → { a: 1; b: 2 }
type R = Mutable<Input>;

Cạm bẫy

  • Bản sao homomorphic giữ readonly: map đồng nhất không đủ; cần -readonly tường minh.
  • Chỉ nông: Mutable gỡ readonly tầng trên; object lồng vẫn readonly trừ khi bạn đệ quy.
  • Biến thể “theo key”: dùng Omit<T, K> & Mutable<Pick<T, K>> — cùng công thức chia như PartialByKeys.

Tóm tắt: -readonly là núm mapped type gỡ tính bất biến từng key.


8 · Readonly 2

Mục tiêu

Chỉ biến key trong K thành readonly; giữ key khác ghi được. Mặc định K là mọi key để khi bỏ qua hành xử như Readonly<T>.

interface Todo {
  title: string;
  description: string;
  completed: boolean;
}
type R = MyReadonly2<Todo, 'title' | 'description'>;
// title & description become readonly; completed stays writable

Vì sao không hiển nhiên: readonly trên key được map áp cho mọi key trong vòng lặp — không đóng băng chỉ tập con trong một lượt.

Cách thử ngây thơ (và vì sao hỏng)

// ❌ Freezes EVERY key, not just K
type MyReadonly2<T, K extends keyof T> = {
  readonly [P in keyof T]: T[P];
};
Hiện đáp án
type MyReadonly2<T, K extends keyof T = keyof T> = Omit<T, K> &
  Readonly<Pick<T, K>>;

Cơ chế: chia-rồi-giao (không cần prettify ở đây)

  • key ghi được giữ nguyên.
  • { readonly [P in keyof Pick<T,K>]: ... } thêm readonly chỉ cho key đã chọn.
  • một object thỏa cả hai nửa.

Từng dòng

  1. ràng buộc K là key hợp lệ; mặc định “mọi key”.
  2. bỏ key đóng băng khỏi nửa này.
  3. mapped type built-in với modifier readonly trên mọi key nó thấy.
  4. giao nửa ghi được và readonly.

Đánh giá type từng bước

interface Todo {
  title: string;
  description: string;
  completed: boolean;
}

// step 1: K = 'title' | 'description'
// step 2: Omit<Todo, 'title' | 'description'>
//         → { completed: boolean }
// step 3: Pick<Todo, 'title' | 'description'>
//         → { title: string; description: string }
// step 4: Readonly<Pick<...>>
//         → { readonly title: string; readonly description: string }
// step 5: intersect
//         → { completed: boolean }
//           & { readonly title: string; readonly description: string }
// step 6: final (intersection is already readable here)
//         → { completed: boolean; readonly title: string; readonly description: string }
type R = MyReadonly2<Todo, 'title' | 'description'>;

Cạm bẫy

  • K extends keyof T = keyof T vs K = keyof T: mệnh đề extends từ chối key sai như 'foo' khi foo không có trên T.
  • Không bắt buộc prettify: khác PartialByKeys, intersection này thường hover gọn; thêm prettify chỉ khi Equal trong test phàn nàn.
  • Đóng băng nông: object lồng trong key đóng băng vẫn mutable ở tầng của chúng.

Tóm tắt: chỉ đóng băng K bằng Omit + Readonly<Pick<...>> + &.


599 · Merge

Mục tiêu

Gộp hai object type thành một. Khi key có ở cả hai, type thứ hai (S) thắng.

type Foo = { a: number; b: string };
type Bar = { b: number; c: boolean };
type R = Merge<Foo, Bar>;
// Expected: { a: number; b: number; c: boolean }

Vì sao không hiển nhiên: F & S giao value trên key chung (b thành string & number → thường never), không phải “bên sau thắng”. Cần mapped type tự viết duyệt union tập key và chọn value bằng conditional.

Cách thử ngây thơ (và vì sao hỏng)

// ❌ Intersection merges values, not keys — shared keys become intersections
type Merge<F, S> = F & S;

type Bad = Merge<{ b: string }, { b: number }>;
// b: string & number → never
// ❌ Also wrong — keyof (F & S) still pairs with intersected values
type Merge<F, S> = { [K in keyof (F & S)]: (F & S)[K] };
// b: never again
Hiện đáp án
type Merge<F, S> = {
  [K in keyof F | keyof S]: K extends keyof S
    ? S[K]
    : K extends keyof F
      ? F[K]
      : never;
};

Cơ chế

  1. Nguồn key keyof F | keyof S — union hai tập key (không dùng keyof (F & S) để chọn value).
  2. Conditional theo key — với mỗi K, kiểm tra S trước (ghi đè), rồi F, rồi never.
  3. Thu hẹp trước khi indexK extends keyof S ? S[K] đảm bảo K là key hợp lệ của S trước khi index.

Từng dòng

  1. lặp qua mọi key xuất hiện ở một trong hai object.
  2. nếu S có key, lấy type của S (bên sau thắng).
  3. ngược lại nếu chỉ F có, lấy F[K].
  4. : never — nhánh dự phòng không tới cho type checker.

Đánh giá type từng bước

type Foo = { a: number; b: string }
type Bar = { b: number; c: boolean }

// step 1: keyof Foo | keyof Bar → 'a' | 'b' | 'c'
// step 2: K = 'a'
//         'a' extends keyof Bar? no
//         'a' extends keyof Foo? yes → Foo['a'] → number
// step 3: K = 'b'
//         'b' extends keyof Bar? yes → Bar['b'] → number  (S wins!)
// step 4: K = 'c'
//         'c' extends keyof Bar? yes → Bar['c'] → boolean
// step 5: assemble
//         → { a: number; b: number; c: boolean }
type R = Merge<Foo, Bar>;

Cạm bẫy

  • Không viết F[K] bừa: khi K'c', K có thể không extend keyof F — conditional thu hẹp trước.
  • Linh hoạt nguồn key: mapped type nhận bất kỳ union string | number | symbol — lần đầu ta rời keyof T.
  • keyof (F & S) vs keyof F | keyof S: với object thường tập key trùng, nhưng F & S đã giao value xung đột — dựng key tường minh giữ logic ghi đè rõ.

Tóm tắt: duyệt keyof F | keyof S và để conditional chọn S[K] trước F[K].


1130 · ReplaceKeys (đổi key bằng as)

Mục tiêu

Cho union các object, thay type của key được nêu bằng type từ object tra cứu Y — nhưng chỉ trên thành viên thực sự có key đó.

type A = { type: 'a'; name: string; flag: number };
type B = { type: 'b'; id: number; flag: number };
type R = ReplaceKeys<A | B, 'name' | 'flag', { name: number; flag: string }>;
// A becomes { type:'a'; name:number; flag:string }
// B becomes { type:'b'; id:number;   flag:string }  (no 'name' to replace)

Vì sao không hiển nhiên: Uunion (A | B), và mapped type phân phối trên union object — mỗi thành viên biến đổi độc lập. Phải rẽ nhánh theo key ở vế value; as dùng đổi tên/bỏ key, không phải đổi type.

Cách thử ngây thơ (và vì sao hỏng)

// ❌ Y[K] when K might not be a key of Y — error or wrong inference
type ReplaceKeys<U, T, Y> = {
  [K in keyof U]: K extends T ? Y[K] : U[K];
};
// Error: Type 'K' cannot be used to index type 'Y'

TypeScript không cho index Y bằng K cho đến khi bạn chứng minh K extends keyof Y.

Hiện đáp án
type ReplaceKeys<U, T, Y> = {
  [K in keyof U]: K extends T ? (K extends keyof Y ? Y[K] : never) : U[K];
};

Cơ chế

  1. [K in keyof U] — với mỗi thành viên union, lặp key của thành viên đó (distribution xử lý AB riêng).
  2. K extends T ? ... : U[K] — đây có phải key cần thay? Nếu không, giữ gốc.
  3. K extends keyof Y ? Y[K] : never — nếu có, tra type mới trong Y; nếu Y thiếu key, ra never.

Từng dòng

  1. U — input union; mapped type phân phối: ReplaceKeys<A|B, ...>ReplaceKeys<A,...> | ReplaceKeys<B,...>.
  2. T — union tên key cần thay (vd. 'name' | 'flag').
  3. Y — object tra cứu chứa type thay thế.
  4. Conditional lồng ở vế value — đổi type không cần as.

Đánh giá type từng bước

Thành viên A trước (phân phối):

type A = { type: 'a'; name: string; flag: number }
type Y = { name: number; flag: string }

// step 1: ReplaceKeys<A, 'name'|'flag', Y> — single member, no union left
// step 2: K = 'type' → 'type' extends 'name'|'flag'? no → A['type'] → 'a'
// step 3: K = 'name' → yes, in Y → Y['name'] → number
// step 4: K = 'flag' → yes, in Y → Y['flag'] → string
// step 5: → { type: 'a'; name: number; flag: string }

Thành viên B:

type B = { type: 'b'; id: number; flag: number }

// step 1: K = 'type' → not in T → 'b'
// step 2: K = 'id'   → not in T → number
// step 3: K = 'flag' → in T, in Y → string
// step 4: → { type: 'b'; id: number; flag: string }
//        (no 'name' key on B — never touched)

Kết quả: ReplaceKeys<A, ...> | ReplaceKeys<B, ...>.

Cạm bẫy

  • Phân phối trên U: input union là lý do B không bao giờ có key name — mỗi hình object chỉ map trên key của chính nó.
  • as vs conditional value: dùng as để đổi tên hoặc lọc key (map sang never để bỏ); dùng conditional vế value để đổi type.
  • Thu hẹp đôi: K extends T rồi K extends keyof Y — cần cả hai trước khi Y[K] hợp lệ.

Tóm tắt: phân phối trên U, rẽ nhánh theo key ở vế value, thu hẹp trước khi index Y.


Bài tập

1. Viết Clone<T> sao chép y hệt object type nhưng hiển thị phẳng khi rê chuột (dùng mẹo làm đẹp).

Lời giải
type Clone<T> = { [K in keyof T]: T[K] };

Map mọi key về chính value của nó vốn là phép đồng nhất; bản thân việc map đã làm phẳng intersection.

Với type đã là intersection, bọc bằng đuôi prettify: T extends infer O ? { [P in keyof O]: O[P] } : never.

2. Vì sao Merge dùng keyof F | keyof S làm nguồn key thay vì keyof (F & S)?

Lời giải

keyof (F & S) thực ra cùng union, nhưng suy luận trên F & S cho key đụng nhau thì mập mờ (value đã là intersection). Tự dựng tập key và tự chọn value giữ quy tắc ghi đè (“bên sau thắng”) rõ ràng và trong tầm kiểm soát.

Nâng cao:cài MutableByKeys<T, K> — bằng cùng công thức chia-rồi-giao với Omit + Mutable<Pick<T, K>>.


Điểm chính

  • Mapped type là { [K in Keys]: Value } — trình biên dịch mở rộng từng key lúc compile; vặn ba núm: modifier, nguồn key, value.
  • readonly/? thêm modifier; -readonly/-? gỡ chúng.
  • “Làm X chỉ với một số key” = chia bằng Omit/Pick, biến đổi một phần, giao, rồi làm đẹp.
  • Nguồn key có thể là bất kỳ union key nào, không chỉ keyof T.
  • Map homomorphic giữ modifier; thay đổi không đối xứng theo key cần chia-rồi-giao hoặc conditional vế value.

Tiếp theo

Phần 3 — Conditional Type & Distribution: cỗ máy A extends B ? X : Y. Ta dựng If, IsNever, IsUnion, AnyOf, chốt hạ quy tắc distributive (và cách tắt bằng [T]), và giải mã “đồ nghề” Equal nổi tiếng đứng sau mọi test.