jvinhit//lab

Search posts

Type to search across journal entries.

navigate open esc close

TypeScript Type Challenges — Explained, with Toggle-to-Reveal Answers

A bilingual walkthrough of classic type-challenges (easy → hard): Pick, Readonly, Exclude, Awaited, Omit, DeepReadonly, Permutation, Union to Intersection, CamelCase — each with a toggle-to-reveal answer and a detailed explanation.

Lập trình ở tầng type, từng bài một

Hệ thống type của TypeScript Turing-complete — bạn có thể tính toán bằng type như tính bằng giá trị. Repo nổi tiếng type-challenges (MIT) là phòng gym tốt nhất cho kỹ năng này.

Bài này đi qua một tập chọn lọc từ dễ tới khó. Mỗi bài hiện mục tiêu trước; đáp án được giấu sau một nút gạt để bạn tự thử, rồi mở ra đọc giải thích. Phần giải thích được viết sao cho dù khái niệm còn mới với bạn, đọc xong bạn sẽ hiểu.

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

Mọi bài giả định bật strict mode.


Năm khối nền tảng cần nắm trước

Trước khi vào bài, hãy hiểu năm công cụ tạo nên gần như mọi đáp án. Đọc kỹ phần này — mọi thứ sau chỉ là năm cái này ghép lại.

1. Generic + ràng buộc — một “hàm type”

Generic là type nhận type làm đầu vào, như hàm nhận giá trị. extends ở đây nghĩa là “đầu vào phải ít nhất là cái này” — một ràng buộc, không phải điều kiện.

type Box<T extends string> = { value: T };
// T extends string = "you may only pass a string into Box"
type A = Box<'hi'>;   // ✓ { value: 'hi' }
type B = Box<number>; // ❌ error: number is not a string

2. Conditional type — if/else ở tầng type

A extends B ? X : Y đọc là: “nếu A gán được cho B thì là X, ngược lại Y”. ""Assignable” đại khái là “vừa khít vào”.

type IsString<T> = T extends string ? true : false;
type A = IsString<'hi'>;  // true
type B = IsString<42>;    // false

3. infer — khớp mẫu và bắt giá trị

Trong conditional, infer X nói: “khớp hình dạng này và bắt phần ở đây vào biến mới X”. Giống destructuring, nhưng cho type.

type ElementType<T> = T extends (infer E)[] ? E : never;
// "if T is an array of some E, give me that E"
type A = ElementType<string[]>; // string
type B = ElementType<number>;   // never (not an array)

4. Mapped type — lặp qua các key

{ [K in Keys]: ... } lặp từng key trong union và dựng object. Với as bạn có thể đổi tên hoặc bỏ key khi lặp. Bạn cũng thêm/bỏ được modifier readonly?.

type Stringify<T> = { [K in keyof T]: string };
type A = Stringify<{ a: number; b: boolean }>; // { a: string; b: string }

5. Template literal + đệ quy — tính toán trên string

Template literal type dựng và khớp mẫu string; kết hợp đệ quy (type tự tham chiếu chính nó) chúng xử lý được string từng ký tự.

type Greet<S extends string> = `Hello, ${S}!`;
type A = Greet<'world'>; // 'Hello, world!'

Nhớ năm cái này, phần còn lại chỉ là ghép nối.


Dễ

4 · Pick

Tự cài Pick<T, K> — tạo type bằng cách chọn tập thuộc tính K từ T.

interface Todo {
  title: string;
  description: string;
  completed: boolean;
}
type TodoPreview = MyPick<Todo, 'title' | 'completed'>;
// Expected: { title: string; completed: boolean }
Hiện đáp án
type MyPick<T, K extends keyof T> = {
  [P in K]: T[P];
};

Từng bước:

  1. keyof T tạo union các key của T.
  2. K extends keyof Tràng buộc: bảo đảm chỉ truyền được key thật sự có trên T.
  3. { [P in K]: ... }mapped type: nó duyệt từng phần tử P của union K.
  4. T[P]indexed access — “type của thuộc tính P trên T”.

Kết quả là object chỉ chứa các key đã chọn, mỗi key giữ type gốc.

7 · Readonly

Biến mọi thuộc tính của T thành readonly.

type ReadonlyTodo = MyReadonly<Todo>;
// Expected: all props become `readonly`
Hiện đáp án
type MyReadonly<T> = {
  readonly [P in keyof T]: T[P];
};

Từng bước:

  1. Lần này map qua mọi key: [P in keyof T].
  2. Copy nguyên type mỗi value bằng T[P].
  3. Phần mới: modifier readonly trước key áp cho mọi thuộc tính.

Nên biết: Modifier cũng bỏ được. -readonly gỡ readonly, -? biến optional thành bắt buộc.

11 · Tuple to Object

Biến một tuple literal thành object có key và value là chính các literal đó.

const tuple = ['tesla', 'model 3', 'model x'] as const;
type Result = TupleToObject<typeof tuple>;
// Expected: { tesla: 'tesla'; 'model 3': 'model 3'; 'model x': 'model x' }
Hiện đáp án
type TupleToObject<T extends readonly (string | number | symbol)[]> = {
  [P in T[number]]: P;
};

Khái niệm cốt lõi — T[number]:

Một tuple như ['a', 'b'] có thể lập chỉ mục theo vị trí (T[0]'a'). Nhưng nếu lập chỉ mục bằng type number thay vì một số cụ thể, TypeScript trả về type của bất kỳ vị trí nào — tức union của mọi phần tử. Vậy T[number] ở đây là 'tesla' | 'model 3' | 'model x'.

Rồi ta map qua union đó, dùng mỗi phần tử P làm cả key lẫn value. Ràng buộc (string | number | symbol)[] là bắt buộc vì chỉ các type đó mới là key object hợp lệ.

14 · First of Array

Lấy type phần tử đầu, hoặc never nếu mảng rỗng.

type A = First<[3, 2, 1]>; // 3
type B = First<[]>;        // never
Hiện đáp án
type First<T extends any[]> = T extends [infer F, ...any[]] ? F : never;

Từng bước:

  1. [infer F, ...any[]]mẫu tuple. Đọc là: “tuple có ô đầu là một type nào đó — bắt làm F — rồi theo sau là bao nhiêu phần tử khác cũng được”.
  2. Với [3, 2, 1], mẫu khớp và F bắt được là 3.
  3. Với [] không có phần tử đầu nên mẫu thất bại, rơi xuống never.

Tại sao là never chứ không phải undefined? Vì ở tầng type, “không tồn tại phần tử nào” được biểu diễn tốt nhất bằng never — type không có giá trị nào.

18 · Length of Tuple

Lấy độ dài tuple dưới dạng number literal.

type N = Length<['a', 'b', 'c']>; // 3
Hiện đáp án
type Length<T extends readonly any[]> = T['length'];

Khái niệm — tuple biết độ dài của chính nó:

Type tuple lưu độ dài dưới dạng number literal trong thuộc tính ẩn length. ['a','b','c']['length'] đúng là 3, không phải number chung chung.

Indexed access T['length'] đọc literal đó. Lưu ý: mảng thường như string[]length: number (kích thước bất kỳ), nên mẹo này chỉ cho số chính xác với tuple, không phải mảng mở.

533 · Concat

Nối hai tuple.

type R = Concat<[1, 2], [3, 4]>; // [1, 2, 3, 4]
Hiện đáp án
type Concat<T extends readonly any[], U extends readonly any[]> = [...T, ...U];

Khái niệm — variadic tuple (spread tuple):

Giống spread mảng ở tầng giá trị với [...a, ...b], bạn spread được type tuple y như vậy ở tầng type. [...T, ...U] trải mọi phần tử của T rồi tới U thành một tuple mới, giữ nguyên thứ tự và literal type.

Đây là nền cho hầu hết bài thao tác tuple (Push, Unshift, Reverse, …).

43 · Exclude

Loại khỏi union T những type gán được cho U.

type R = MyExclude<'a' | 'b' | 'c', 'a'>; // 'b' | 'c'
Hiện đáp án
type MyExclude<T, U> = T extends U ? never : T;

Khái niệm lớn — distributive conditional type:

Dòng này chạy được nhờ một quy tắc đặc biệt: khi type được kiểm tra (vế trái extends) là tham số trần (chỉ T, không bọc) là union, conditional được áp cho từng phần tử riêng, rồi gộp kết quả lại.

Xem nó bung ra với 'a' | 'b' | 'c' loại 'a':

// distributes into three independent checks:
('a' extends 'a' ? never : 'a')   // → never
| ('b' extends 'a' ? never : 'b') // → 'b'
| ('c' extends 'a' ? never : 'c') // → 'c'
// = never | 'b' | 'c'
// = 'b' | 'c'   (never vanishes from a union)

Hai điều cần nhớ: distribution cần tham số trần (bọc [T] sẽ tắt), và never luôn biến mất khỏi union.

189 · Awaited

Bóc đệ quy type giá trị của một type giống Promise.

type R = MyAwaited<Promise<Promise<string>>>; // string
Hiện đáp án
type MyAwaited<T extends PromiseLike<any>> =
  T extends PromiseLike<infer V>
    ? V extends PromiseLike<any>
      ? MyAwaited<V>
      : V
    : never;

Từng bước:

  1. T extends PromiseLike<infer V> khớp một promise và bắt type đã resolve vào V.
  2. Rồi hỏi: bản thân V có lại là promise không? Nếu có → đệ quy: gọi MyAwaited<V> để bóc lớp tiếp. Nếu không → đã tới giá trị thật, trả về V (đây là base case dừng đệ quy).

Với Promise<Promise<string>>: lần đầu V = Promise<string> (vẫn là promise → đệ quy), lần sau V = string (không phải promise → trả string). Mọi đệ quy phải có base case, nếu không type sẽ bung vô tận.


Trung bình

2 · Get Return Type

Cài ReturnType<T> mà không dùng bản built-in.

type R = MyReturnType<() => 'hello'>; // 'hello'
Hiện đáp án
type MyReturnType<T> = T extends (...args: any[]) => infer R ? R : never;

Khái niệm — infer hoạt động ở bất kỳ vị trí nào trong mẫu:

Ta khớp T với hình dạng hàm tổng quát: (...args: any[]) => infer R. (...args: any[]) nghĩa là “nhận tham số bất kỳ — ta không quan tâm”. Phần thú vị là infer R đặt ở vị trí return, bắt giá trị hàm trả về.

Bài học: bạn có thể đặt infer vào bất kỳ ô nào của mẫu — tham số, return, phần tử mảng, nội dung promise — để rút đúng phần bạn cần.

3 · Omit

Cài Omit<T, K>T bỏ đi các key trong K.

type R = MyOmit<Todo, 'description'>; // { title: string; completed: boolean }
Hiện đáp án
type MyOmit<T, K extends keyof T> = {
  [P in keyof T as P extends K ? never : P]: T[P];
};

Khái niệm — đổi key bằng as:

Mapped type đổi tên key được bằng as: [P in keyof T as NewKey]. Mẹo then chốt: nếu đổi một key thành never, key đó bị loại khỏi kết quả.

Vậy ta duyệt mọi key P của T và tính key mới:

P extends K ? never : P
// if P is one of the keys to omit → never (drops it)
// otherwise → keep P unchanged

'title''completed' giữ lại, 'description' map thành never và biến mất.

9 · Deep Readonly

Biến object readonly đệ quy (cả object lồng nhau), nhưng để nguyên hàm/primitive.

type X = { a: () => 22; b: string; c: { d: boolean } };
type R = DeepReadonly<X>;
// Expected: a unchanged, b: string, c: { readonly d: boolean }, all readonly
Hiện đáp án
type DeepReadonly<T> = {
  readonly [K in keyof T]: T[K] extends Record<string, unknown>
    ? DeepReadonly<T[K]>
    : T[K];
};

Từng bước:

  1. Bắt đầu như Readonly thường: map mọi key và thêm readonly.
  2. Với mỗi value, quyết định có đào sâu hơn không. Phép thử T[K] extends Record<string, unknown> hỏi “value này có phải object thuần không?”. Object → đệ quy với DeepReadonly<T[K]> để object lồng cũng readonly. Hàm hoặc primitive → không khớp Record<string, unknown>, nên trả về nguyên.

Đây là đệ quy trên cấu trúc dữ liệu (cây object) chứ không phải danh sách — base case là “value không phải object”.

10 · Tuple to Union

Chuyển tuple thành union các phần tử.

type R = TupleToUnion<[123, '456', true]>; // 123 | '456' | true
Hiện đáp án
type TupleToUnion<T extends readonly any[]> = T[number];

Khái niệm — tái dùng T[number]:

Cùng ý tưởng trong Tuple to Object. Lập chỉ mục type tuple bằng type number hỏi “type ở bất kỳ chỉ mục nào?”, và đáp án là union mọi phần tử. Vậy [123, '456', true][number]123 | '456' | true.

Nhận ra những mẫu nhỏ tái dùng được như vậy là một nửa của việc giỏi type challenges.

110 · Capitalize

Viết hoa chữ cái đầu của một string type.

type R = MyCapitalize<'hello world'>; // 'Hello world'
Hiện đáp án
type MyCapitalize<S extends string> = S extends `${infer First}${infer Rest}`
  ? `${Uppercase<First>}${Rest}`
  : S;

Khái niệm — tách string bằng template literal:

`${infer First}${infer Rest}` là một mẫu string. TypeScript khớp sao cho First lấy một ký tựRest lấy phần còn lại. Với 'hello world': First = 'h', Rest = 'ello world'.

Rồi dùng intrinsic có sẵn Uppercase<> lên đúng ký tự đầu và dán string lại. Chuỗi rỗng '' không khớp mẫu hai phần nên rơi xuống S nguyên — đó là base case.

296 · Permutation

Sinh mọi hoán vị của một union dưới dạng tuple.

type R = Permutation<'A' | 'B' | 'C'>;
// ['A','B','C'] | ['A','C','B'] | ['B','A','C'] | ... (6 tuples)
Hiện đáp án
type Permutation<T, K = T> = [T] extends [never]
  ? []
  : K extends K
    ? [K, ...Permutation<Exclude<T, K>>]
    : never;

Bài này gộp hai mẹo nâng cao. Đi từng cái:

Mẹo 1 — giữ bản sao của union với K = T. Ta đặt mặc định tham số thứ hai K = T. Cần vậy vì dòng K extends K cố tình kích hoạt distribution trên K, và ta muốn một tham chiếu ổn định tới toàn bộ union gốc (T) để trừ đi.

Mẹo 2 — [T] extends [never] để phát hiện union rỗng. Cần base case: khi không còn gì để hoán vị, trả []. Nhưng không thể viết T extends never ? ... trực tiếp, vì nếu T là union (hoặc never) nó sẽ distribute và sai. Bọc cả hai vế trong tuple, [T] extends [never], tắt distribution để có phép kiểm tra đúng.

Ghép lại: K extends K distribute trên từng phần tử K. Với mỗi cái, ta đặt K lên đầu và đệ quy trên phần còn lại Exclude<T, K>:

// for 'A' | 'B' | 'C':
['A', ...Permutation<'B' | 'C'>]  // → ['A','B','C'] | ['A','C','B']
| ['B', ...Permutation<'A' | 'C'>] // → ['B','A','C'] | ['B','C','A']
| ['C', ...Permutation<'A' | 'B'>] // → ['C','A','B'] | ['C','B','A']

Khó

55 · Union to Intersection

Chuyển union A | B thành intersection A & B.

type R = UnionToIntersection<{ a: 1 } | { b: 2 }>; // { a: 1 } & { b: 2 }
Hiện đáp án
type UnionToIntersection<U> = (
  U extends any ? (arg: U) => void : never
) extends (arg: infer I) => void
  ? I
  : never;

Bài kinh điển. Mẹo nằm ở contravariance (nghịch biến).

Khái niệm nền — tính nghịch biến của tham số hàm. Vị trí tham số hàm là nghịch biến: hàm nhận A chỉ tương thích với nơi mong đợi A hoặc cụ thể hơn. Khi TypeScript phải tìm một type tham số thoả nhiều type hàm cùng lúc, nó gộp bằng & (intersection), không phải |.

Từng bước:

  1. distribute U thành union các hàm.
  2. Rồi ta infer I làm type tham số duy nhất từ cả union hàm đó.
  3. Nhờ nghịch biến, I duy nhất hợp cho cả hai hàm là intersection {a:1} & {b:2}.

Không cần tự suy lại mỗi lần — nhận ra hình dạng (U extends any ? (arg: U) => void : never) là “đồ nghề” chuẩn cho “union → intersection”.

114 · CamelCase

Chuyển string kebab-case thành camelCase.

type R = CamelCase<'foo-bar-baz'>; // 'fooBarBaz'
type E = CamelCase<'foo--bar'>;    // 'foo-Bar' (edge case preserved)
Hiện đáp án
type CamelCase<S extends string> = S extends `${infer Head}-${infer Tail}`
  ? Tail extends Capitalize<Tail>
    ? `${Head}-${CamelCase<Tail>}`
    : `${Head}${CamelCase<Capitalize<Tail>>}`
  : S;

Từng bước:

  1. tách string ở dấu gạch đầu tiên. Với 'foo-bar-baz': Head = 'foo', Tail = 'bar-baz'.
  2. Phép kiểm tra trong Tail extends Capitalize<Tail> xử lý ca hai dấu gạch. Nếu Tail đã viết hoa, tức ký tự sau dấu gạch là một dấu gạch nữa. Khi đó giữ dấu gạch và đệ quy.
  3. Ngược lại (ca thường) viết hoa chữ đầu của Tail và đệ quy bỏ dấu gạch.
  4. Khi hết dấu gạch, mẫu thất bại và trả về S — base case.

Lần theo 'foo-bar-baz': foo + Camel(Bar-baz) → foo + Bar + Camel(Baz) → fooBarBaz.


Cách luyện tập

  1. Đọc test case trước: chúng định nghĩa hành vi chính xác — kể cả edge case như foo--bar.
  2. Dùng năm khối nền tảng: hầu hết đáp án là conditional + infer, mapped type, hoặc đệ quy.
  3. Để ý distribution: tham số trần trên union sẽ phân phối; bọc [T] để dừng.
  4. Luôn tìm base case: mọi type đệ quy cần điều kiện dừng (tuple rỗng, string rỗng, “không phải object”).
  5. Dùng Playground: rê chuột lên type và xem nó resolve từng bước.

Kỹ năng này tích luỹ nhanh: một khi infer + conditional + đệ quy đã thông, bạn đọc (và viết) được những utility type hiểm hóc nhất một cách tự tin.

Nguồn & bộ đầy đủ: