jvinhit//lab

Search posts

Type to search across journal entries.

navigate open esc close

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:

  1. mapped type duyệt mọi key của T.
  2. conditional type theo từng key: cái qua được thành literal key K, cái rớt thành never.
  3. indexed access trên mapped type: TypeScript lấy value (K | K | never | …) và tạo union; never là đơ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

  • never trong 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 keySomeCondition được tính với K trong 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ủa T; 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

PieceRole
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
  1. duyệt mọi key của T.
  2. gỡ optional khỏi K khi map, nên b?: number được thử như { b: number } chứ không phải { b?: number }.
  3. object một thuộc tính, ví dụ Pick<{a: string; b?: number}, 'b'>{ b?: number }.
  4. nếu object rỗng thỏa type một key thì key đó optional → loại (never); không thì giữ K.
  5. 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ới a bắt buộc vẫn fail {} extends, nên a được giữ.
  • Kết quả là union literal string, không phải object — dùng đổi key (as) khi cần object (xem GetRequired bê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 : never vs ? never : K.
  • Key có ?: undefined vẫn là optional{} gán được, nên có trong OptionalKeys.
  • Đừng nhầm với “value có thể undefined”{ a: string | undefined } với a bắt buộc thuộc RequiredKeys, không phải đây.

Tóm tắt: giữ K khi {} extends Pick<T, K>true — đúng ca optional.

Giống RequiredKeys nhưng đổi nhánh: giữ K khi {} extends Pick<T, K>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>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

PieceRole
[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ỡ ?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
};
  1. mapped type có đổi key; key đầu ra từ biểu thức as, không trực tiếp từ K.
  2. với optional b?: number, đây là number (không undefined); với bắt buộc a: string, vẫn là string.
  3. string extends stringtrue (bắt buộc); number | undefined extends numberfalse (optional mang undefined).
  4. type value lấy từ T gố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] vs as[keyof T] ra union; as ra 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 : K chỉ giữ key optional.
  • Key bắt buộc có \| undefined trong valuea: string | undefined (bắt buộc) fail extends so với string, 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

PieceRole
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, VBắt literal key và type value từ mỗi chỗ gọi qua generic K, V
K extends keyof T ? never : KConditional 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
};
  1. các option đã tích luỹ; bắt đầu rỗng.
  2. key phải là string; K được suy ra là literal string ('foo', không phải string).
  3. type value suy ra từ đối số thứ hai (123number).
  4. nếu K đã là key của T, type tham số là never → không truyền được giá trị nào → lỗi biên dịch.
  5. gỡ K cũ (nếu có), rồi thêm { [K]: V }; giao gộp thành object phẳng.
  6. 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

  • never trên tham số, không phải return type — trả never từ option() sẽ gãy chain; làm key thành never chỉ chặn lời gọi sai.
  • Omit<T, K> & Record<K, V> vs T & Record<K, V> — giao trên key trùng ra T[K] & V (thường never), không thay sạch; Omit trước tránh điều đó.
  • Suy ra literal cần đối số cụ thểoption(someString, v) nới K thành string và 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 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>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ề K hoặc never, rồi index [keyof T] để có union.
  • Tính optional phát hiện được: {} extends Pick<T, K>true đúng với key optional.
  • Để trả object các thuộc tính đã lọc, dùng đổi key as thay 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.