TS Type Challenges · Part 9 — Hard Utility Types
Part 9: type utilities from real libraries. Split objects by required vs optional keys with GetRequired, RequiredKeys and OptionalKeys, then build a fluent, type-safe Chainable builder — each with a toggle-to-reveal answer.
Đây là Phần 9 của series 12 bài về TypeScript type challenges. Đây là những bài “khó” thực sự có ích: lọc key của object theo tính bắt buộc, và gán kiểu cho fluent builder sao cho mỗi lời gọi tinh chỉnh type kết quả. Chúng gộp mọi thứ tới giờ — mapped type, conditional, đổi key, và mẹo indexed-access-thành-union.
Ghi nguồn: các đáp án theo type-challenges (giấy phép MIT); số khớp ID chính thức.
Mẫu “lọc key”
Mục tiêu: cho một object type T, sinh union tên key thỏa điều kiện — ví dụ “chỉ những key bắt buộc”. Nghe như Object.keys().filter() lúc runtime, nhưng ở tầng type không có method .filter() trên union.
Vì sao không hiển nhiên: keyof T luôn trả mọi key. Bạn không thể “xóa” thành viên khỏi union bằng if đơn giản — conditional chỉ chạy bên trong mapped type hoặc làm hai nhánh của extends.
Thử ngây thơ
Bạn có thể thử trừ key thủ công — không mở rộng được và không diễn tả được “key này có optional không?”:
// ❌ No built-in "filter keyof" — and you cannot loop at the type level
type RequiredKeysNaive<T> = keyof T; // returns ALL keys, not just required ones
Cơ chế: map → conditional → index
Chiêu kinh điển map mỗi key về chính nó hoặc never, rồi index mapped type bằng [keyof T] để gập value thành union:
type Keys = {
[K in keyof T]: SomeCondition extends true ? K : never;
}[keyof T];
// ▲ never drops out of the union
Đọc theo ba lớp:
- mapped type duyệt mọi key của
T. - conditional type theo từng key: cái qua được thành literal key
K, cái rớt thànhnever. - indexed access trên mapped type: TypeScript lấy value (
K | K | never | …) và tạo union;neverlà đơn vị của union nên biến mất.
Từng bước trên input nhỏ
type T = { a: string; b?: number };
// step 1: mapped type produces an object of per-key results
type Step1 = {
[K in keyof T]: K extends 'a' ? K : never; // pretend we only keep 'a'
};
// step 1 result: { a: 'a'; b: never }
// step 2: index by keyof T → union of values
type Step2 = Step1[keyof T];
// step 2 result: 'a' | never → 'a'
Cạm bẫy
nevertrong union biến mất — đó là mục đích; đừng cố “lọc never” riêng.- Điều kiện phải theo từng key —
SomeConditionđược tính vớiKtrong scope; điều kiện ngoài mapped type không nhìn thấy từng key. [keyof T]gom value, không phải key — key của mapped type vẫn là mọi key củaT; bước index đọc vị trí value.
Tóm tắt: map mỗi key về K hoặc never, rồi [keyof T] biến phần sống sót thành union.
2575 · RequiredKeys
Mục tiêu: trích union các key bắt buộc (không optional) từ một object type.
type R = RequiredKeys<{ a: string; b?: number; c: boolean }>;
// Expected: 'a' | 'c'
Vì sao không hiển nhiên: TypeScript không có isOptional<K> sẵn. Key optional được mã hóa là T[K] có thể gồm undefined, nhưng key bắt buộc cũng có thể được gõ string | undefined — nên chỉ xem type value là không đủ tin. Mẹo thử khả năng gán của {} cho object một key thay thế.
Thử ngây thơ
// ❌ Value-type check: optional keys have undefined, but required keys CAN too
type RequiredKeysNaive<T> = {
[K in keyof T]: undefined extends T[K] ? never : K;
}[keyof T];
// Fails on: { a: string | undefined } — 'a' is required but has undefined in its type
Các cơ chế kết hợp
| Piece | Role |
|---|---|
Pick<T, K> | Tách một thuộc tính thành { k: T[K] } để thử từng key |
{} extends Pick<T, K> | Phát hiện optional qua khả năng gán |
[K in keyof T]-? | Gỡ ? khi map để phép thử không bị lệch |
[keyof T] | Gom các literal K còn lại thành union |
Hiện đáp án
type RequiredKeys<T> = {
[K in keyof T]-?: {} extends Pick<T, K> ? never : K;
}[keyof T];Từng dòng
type RequiredKeys<T> = {
[K in keyof T]-?: {} extends Pick<T, K> ? never : K;
//▲ 1 ▲ 2 ▲ 3 ▲ 4
}[keyof T];
//▲ 5- duyệt mọi key của
T. - gỡ optional khỏi
Kkhi map, nênb?: numberđược thử như{ b: number }chứ không phải{ b?: number }. - object một thuộc tính, ví dụ
Pick<{a: string; b?: number}, 'b'>→{ b?: number }. - nếu object rỗng thỏa type một key thì key đó optional → loại (
never); không thì giữK. - union các
Kđược giữ:'a' | never | 'c'→'a' | 'c'.
Đánh giá type từng bước
Input: T = { a: string; b?: number; c: boolean }
// step 1: expand the mapped type (one entry per key)
type Mapped = {
a: {} extends Pick<T, 'a'> ? never : 'a';
b: {} extends Pick<T, 'b'> ? never : 'b';
c: {} extends Pick<T, 'c'> ? never : 'c';
};
// step 2: evaluate Pick for each key
// Pick<T, 'a'> = { a: string }
// Pick<T, 'b'> = { b?: number } ← optional marker present
// Pick<T, 'c'> = { c: boolean }
// step 3: evaluate {} extends ... for each
// {} extends { a: string } → false (must supply 'a')
// {} extends { b?: number } → true (may omit 'b')
// {} extends { c: boolean } → false (must supply 'c')
// step 4: resolve conditionals
type MappedResult = {
a: 'a'; // false branch → keep K
b: never; // true branch → drop
c: 'c'; // false branch → keep K
};
// step 5: index by keyof T
type Result = MappedResult[keyof T];
// 'a' | never | 'c' → 'a' | 'c' ✓Cạm bẫy
- Đừng bỏ
-?— không có nó,{} extends { b?: number }có thể khác{} extends { b: number }tùy strictness;-?chuẩn hóa phép thử. {} extends Pick<T, K>phát hiện optional, không phải “có thể undefined” —{ a: string | undefined }vớiabắt buộc vẫn fail{} extends, nênađược giữ.- Kết quả là union literal string, không phải object — dùng đổi key (
as) khi cần object (xemGetRequiredbên dưới).
Tóm tắt: key optional là những key mà {} thỏa Pick<T, K>; đảo phép thử đó và gom phần sống bằng [keyof T].
Cái hay — tính optional phát hiện được qua {} extends Pick<...> — là một trong các mẹo tái dùng nhiều nhất khi gõ type nâng cao.
2587 · OptionalKeys
Mục tiêu: bản đối xứng của RequiredKeys — trả union các key được đánh dấu optional bằng ?.
type R = OptionalKeys<{ a: string; b?: number; c?: boolean }>;
// Expected: 'b' | 'c'
Vì sao không hiển nhiên: là cùng máy móc với RequiredKeys nhưng đảo nhánh — một khi hiểu phép thử {} extends Pick, cả hai type chỉ là bật tắt một dòng. phần không hiển nhiên là tin phép thử đối xứng: optional ↔ {} gán được.
Thử ngây thơ
// ❌ Subtract RequiredKeys from keyof — no "set minus" operator on unions
type OptionalKeysNaive<T> = Exclude<keyof T, RequiredKeys<T>>; // works but requires two passes
// Better: one-pass filter with the same template, branches swapped
Exclude<keyof T, RequiredKeys<T>> thực ra chạy được, nhưng phải tính RequiredKeys trước — đáp án chuẩn là một lượt map với conditional đảo ngược.
Hiện đáp án
type OptionalKeys<T> = {
[K in keyof T]-?: {} extends Pick<T, K> ? K : never;
}[keyof T];Từng dòng
Chỉ dòng 4 khác RequiredKeys:
{} extends Pick<T, K> ? K : never;
// true → keep K (optional)
// false → never (required)Đánh giá type từng bước
Input: T = { a: string; b?: number; c?: boolean }
// step 1: Pick each key
// Pick<T, 'a'> = { a: string }
// Pick<T, 'b'> = { b?: number }
// Pick<T, 'c'> = { c?: boolean }
// step 2: {} extends Pick<T, K>?
// 'a' → false
// 'b' → true
// 'c' → true
// step 3: map results
type Mapped = { a: never; b: 'b'; c: 'c' };
// step 4: [keyof T]
type Result = Mapped[keyof T];
// never | 'b' | 'c' → 'b' | 'c' ✓Cạm bẫy
- Chỉ đảo nhánh, còn lại giống hệt — lỗi copy-paste hay gặp; kiểm tra lại
? K : nevervs? never : K. - Key có
?: undefinedvẫn là optional —{}gán được, nên có trongOptionalKeys. - Đừng nhầm với “value có thể undefined” —
{ a: string | undefined }vớiabắt buộc thuộcRequiredKeys, không phải đây.
Tóm tắt: giữ K khi {} extends Pick<T, K> là true — đúng ca optional.
Giống RequiredKeys nhưng đổi nhánh: giữ K khi {} extends Pick<T, K> là true (ca optional). Một khi đã nhận ra phép thử optional, cả hai đều bật ra từ cùng khuôn.
57 · GetRequired
Mục tiêu: chỉ giữ thuộc tính bắt buộc và trả object type mới — không chỉ union tên key.
type R = GetRequired<{ a: string; b?: number }>;
// Expected: { a: string }
Vì sao không hiển nhiên: RequiredKeys cho bạn 'a', nhưng Pick<T, RequiredKeys<T>> khó viết vì RequiredKeys<T> là union, không phải ràng buộc keyof để chèn trực tiếp không cần type phụ. Bài muốn một mapped type lọc và dựng lại object trong một lượt bằng đổi key (as).
Thử ngây thơ
// ❌ Pick needs the union first — two-step, extra type
type GetRequiredNaive<T> = Pick<T, RequiredKeys<T>>;
// ❌ keyof Required<T> returns ALL keys — Required<T> makes optional keys required too
type GetRequiredWrong<T> = {
[K in keyof Required<T>]: T[K];
};
// Returns { a: string; b: number } for { a: string; b?: number } — includes optional keys!
Các cơ chế kết hợp
| Piece | Role |
|---|---|
[K in keyof T as …] | Đổi key — đổi tên mỗi key thành K hoặc never; key never bị loại khỏi object kết quả |
Required<T>[K] | Utility có sẵn: cùng key, mọi thuộc tính bắt buộc (gỡ ? và undefined khỏi thành viên optional) |
T[K] extends Required<T>[K] | Phát hiện thuộc tính gốc đã ở dạng bắt buộc chưa |
Hiện đáp án
type GetRequired<T> = {
[K in keyof T as T[K] extends Required<T>[K] ? K : never]: T[K];
};Từng dòng
type GetRequired<T> = {
[K in keyof T as T[K] extends Required<T>[K] ? K : never]: T[K];
// ▲ 1 ▲ 2 ▲ 3 ▲ 4
};- mapped type có đổi key; key đầu ra từ biểu thức
as, không trực tiếp từK. - với optional
b?: number, đây lànumber(khôngundefined); với bắt buộca: string, vẫn làstring. string extends string→true(bắt buộc);number | undefined extends number→false(optional mangundefined).- type value lấy từ
Tgốc, không từRequired<T>.
Đánh giá type từng bước
Input: T = { a: string; b?: number }
// step 1: compute Required<T>
type Req = Required<T>;
// { a: string; b: number } — 'b' lost its optionality
// step 2: for each K, compare T[K] vs Required<T>[K]
// K = 'a': T['a'] = string, Required<T>['a'] = string
// string extends string → true → keep key 'a'
// K = 'b': T['b'] = number | undefined, Required<T>['b'] = number
// number | undefined extends number → false → remap to never (drop)
// step 3: build output object (never keys omitted)
type Result = { a: string };
// ✓Cạm bẫy
[keyof T]vsas—[keyof T]ra union;asra object. Chọn công cụ theo hình dạng cần.GetOptionalđảo phép thử —T[K] extends Required<T>[K] ? never : Kchỉ giữ key optional.- Key bắt buộc có
\| undefinedtrong value —a: string | undefined(bắt buộc) failextendsso vớistring, nên bị loại; khớp phân biệt của TS giữa optional key và undefined trong value.
Tóm tắt: đổi mỗi key thành K hoặc never dựa trên T[K] đã khớp dạng bắt buộc chưa; key optional mang undefined và fail.
So với RequiredKeys: cái đó trả key; cái này trả object dựng từ các key đó. (GetOptional y vậy nhưng lật phép thử.)
12 · Chainable Options
Mục tiêu: gán kiểu cho fluent builder mà mỗi .option(key, value) tích luỹ cặp key/value vào object .get() trả về — lỗi biên dịch khi trùng key.
declare const config: Chainable;
const result = config
.option('foo', 123)
.option('name', 'type-challenges')
.option('bar', { value: 'Hello World' })
.get();
// result: { foo: number; name: string; bar: { value: string } }
Vì sao không hiển nhiên: mỗi .option() phải nhớ mọi key/value trước đó ở tầng type, cấm thêm lại key đã có, vẫn trả this để chain — không có state mutable, chỉ tham số type đệ quy mang qua các return type.
Thử ngây thơ
// ❌ Accumulates keys but allows duplicates silently
type ChainableNaive<T = {}> = {
option<K extends string, V>(key: K, value: V): ChainableNaive<T & Record<K, V>>;
get(): T;
};
// .option('foo', 1).option('foo', 'two') → { foo: number & string } — no error!
// ❌ Using `never` as return type blocks chaining entirely
type ChainableBroken<T = {}> = {
option<K extends keyof T>(key: K, value: unknown): never;
get(): T;
};
Các cơ chế kết hợp
| Piece | Role |
|---|---|
Chainable<T = {}> | Trạng thái trong tham số type — T lớn dần mỗi lời gọi |
infer via generics K, V | Bắt literal key và type value từ mỗi chỗ gọi qua generic K, V |
K extends keyof T ? never : K | Conditional trên tham số key — key trùng thành never, lời gọi lỗi type |
Omit<T, K> & Record<K, V> | Thay type của key thay vì giao các type xung đột |
Hiện đáp án
type Chainable<T = {}> = {
option<K extends string, V>(
key: K extends keyof T ? never : K,
value: V,
): Chainable<Omit<T, K> & Record<K, V>>;
get(): T;
};Từng dòng
type Chainable<T = {}> = {
// ▲ 1
option<K extends string, V>(
// ▲ 2 ▲ 3
key: K extends keyof T ? never : K,
// ▲ 4
value: V,
): Chainable<Omit<T, K> & Record<K, V>>;
// ▲ 5
get(): T;
// ▲ 6
};- các option đã tích luỹ; bắt đầu rỗng.
- key phải là string;
Kđược suy ra là literal string ('foo', không phảistring). - type value suy ra từ đối số thứ hai (
123→number). - nếu
Kđã là key củaT, type tham số lànever→ không truyền được giá trị nào → lỗi biên dịch. - gỡ
Kcũ (nếu có), rồi thêm{ [K]: V }; giao gộp thành object phẳng. - trả type đã tích luỹ đầy đủ.
Đánh giá type từng bước
declare const config: Chainable;
// step 0: T = {}
config.option('foo', 123);
// step 1: K = 'foo', V = number
// 'foo' extends keyof {} ? never : 'foo' → 'foo' (ok)
// new T = Omit<{}, 'foo'> & Record<'foo', number> = { foo: number }
// return type: Chainable<{ foo: number }>
config.option('name', 'type-challenges');
// step 2: T = { foo: number }
// K = 'name', V = string
// 'name' extends 'foo' ? never : 'name' → 'name' (ok)
// new T = { foo: number; name: string }
config.option('bar', { value: 'Hello World' });
// step 3: T = { foo: number; name: string }
// K = 'bar', V = { value: string }
// new T = { foo: number; name: string; bar: { value: string } }
config.get();
// step 4: returns T = { foo: number; name: string; bar: { value: string } } ✓
// step 5 (error demo): on Chainable<{ foo: number }>
// .option('foo', 'again')
// 'foo' extends 'foo' ? never : 'foo' → never
// Argument of type '"again"' is not assignable to parameter of type 'never' ✗Cạm bẫy
nevertrên tham số, không phải return type — trảnevertừoption()sẽ gãy chain; làmkeythànhneverchỉ chặn lời gọi sai.Omit<T, K> & Record<K, V>vsT & Record<K, V>— giao trên key trùng raT[K] & V(thườngnever), không thay sạch;Omittrước tránh điều đó.- Suy ra literal cần đối số cụ thể —
option(someString, v)nớiKthànhstringvà mất độ chính xác chặn key trùng. - Mặc định
T = {}— không có thì người gọi phải tự cung cấp type ban đầu.
Tóm tắt: mang option tích luỹ trong T, suy ra literal K/V mỗi lần, chặn key trùng bằng never, gộp bằng Omit & Record.
Đây là ví dụ kinh điển của mang trạng thái trong tham số type qua các lời gọi method — cũng là kỹ thuật sau query builder, thư viện form, và ORM.
Bài tập
1. Cài GetOptional<T> (phần bù của GetRequired).
Lời giải
type GetOptional<T> = {
[K in keyof T as T[K] extends Required<T>[K] ? never : K]: T[K];
};Cùng hình dạng GetRequired, đổi nhánh — giữ key khi thuộc tính có khác dạng bắt buộc (tức vẫn mang undefined từ optional).
// step 1: T = { a: string; b?: number }
// 'a': string extends string → true → never (drop)
// 'b': number | undefined extends number → false → keep 'b'
// Result: { b?: number } ✓2. Trong Chainable, vì sao type của key phải là K extends keyof T ? never : K chứ không chỉ K?
Lời giải
Đề cấm thêm lại key đã có. Với chỉ key: K, .option('foo', …) trùng vẫn qua type-check và sinh foo: OldType & NewType qua giao — thường là never, nhưng âm thầm. Map tham số key trùng về never khiến .option() sai không gọi được — TypeScript báo “not assignable to never” ngay chỗ gọi.
Nâng cao:dựng ToRequired<T> và ToPartial<T> lật mọi thuộc tính, rồi ghép với GetRequired/GetOptional để đi vòng một object type.
Điểm chính
- Lọc key bằng cách map mỗi key về
Khoặcnever, rồi index[keyof T]để có union. - Tính optional phát hiện được:
{} extends Pick<T, K>làtrueđúng với key optional. - Để trả object các thuộc tính đã lọc, dùng đổi key
asthay vì[keyof T]. - Mang trạng thái trong tham số type để gán kiểu cho builder fluent, tích luỹ.
Tiếp theo
Phần 10 — Parser & Máy trạng thái: parse chuỗi ở tầng type. Ta đổi camelCase sang kebab-case, gập một tuple thành object lồng nhau, và dựng một parser định dạng printf thực thụ duyệt string như một máy trạng thái.