jvinhit//lab

Search posts

Type to search across journal entries.

navigate open esc close

TS Type Challenges · Part 5 — Recursion over Tuples

Part 5: peel one element off and loop. Build Push, Unshift, Reverse, Includes, Flatten and Join with recursion over tuples, learn the accumulator pattern, and understand the recursion depth limit — each with a toggle-to-reveal answer.

Đây là Phần 5 của series 12 bài về TypeScript type challenges. Ở Phần 4 ta học cách bóc một phần tử ở hai đầu tuple. Đệ quy biến cú bóc đơn lẻ đó thành một vòng lặp — xử lý phần đầu, rồi đệ quy trên phần đuôi tới khi hết.

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


Khuôn đệ quy

Mục tiêu

Ở runtime bạn duyệt danh sách bằng for, forEach, hoặc map. Ở tầng type không có từ khóa vòng lặp — bạn mô phỏng vòng lặp bằng cách gọi cùng một type alias trên input nhỏ hơn cho tới khi chạm điều kiện dừng. Đó là đệ quy ở tầng type.

Cách thử ngây thơ

Bản năng đầu tiên có thể là “đi” tuple bằng cách index từng vị trí:

// ❌ naive — you cannot loop over indices at the type level
type Reverse<T extends any[]> = [T[2], T[1], T[0]]; // only works for fixed length 3

Cách này hard-code ba vị trí và hỏng ngay khi tuple có độ dài khác. Bạn cần cơ chế chạy được với mọi độ dài mà không cần biết trước.

Cơ chế: bóc, xử lý, đệ quy

Mọi bài đệ quy tuple đều theo cùng khung:

type Loop<T extends any[]> = T extends [infer Head, ...infer Tail]
  ? /* do something with Head, then */ Loop<Tail>  // recursive case
  : /* base case (usually [] or '') */;

Đọc như if / else trên hình dạng của T:

PieceWhat it doesRuntime analogy
T extends [infer Head, ...infer Tail]Pattern-match: “does T have at least one element?” If yes, bind the first to Head and the rest to Tailconst [head, ...tail] = arr
? … Loop<Tail>Recursive case — do something with Head, then repeat on the smaller tuple TailProcess head, call yourself on tail
: …Base caseT did not match the pattern (empty tuple []), so stoparr.length === 0 → return

Hai câu luôn phải trả lời trước khi viết logic: làm gì với Head?base case nào dừng vòng lặp?

Từng dòng

type Loop<T extends any[]> =          // 1️⃣ input must be an array/tuple type
  T extends [infer Head, ...infer Tail]  // 2️⃣ destructure: first element + rest
    ? Loop<Tail>                        // 3️⃣ recurse on the strictly smaller Tail
    : never;                            // 4️⃣ base case: empty T → stop (placeholder)
  1. ràng buộc input để TypeScript biết đó là dạng tuple.
  2. destructure tuple variadic ở tầng type; Head là một phần tử, Tail luôn ngắn hơn một phần tử.
  3. nhánh ? chỉ chạy khi pattern khớp (tuple không rỗng).
  4. nhánh :base case — khi T[], pattern không khớp và đệ quy dừng.

Đánh giá cụ thể: đếm phần tử

Đây là bộ đếm tối thiểu để bạn xem compiler bung đệ quy:

type Length<T extends any[]> = T extends [infer _, ...infer Tail]
  ? Length<Tail> extends infer N extends number
    ? [1, ...N[]]['length']
    : never
  : 0;

type Demo = Length<[1, 2, 3]>; // 3
// step 0: Length<[1, 2, 3]>
//   T = [1, 2, 3]  →  matches [Head, ...Tail]  →  Head=1, Tail=[2, 3]
//   recurse → Length<[2, 3]>

// step 1: Length<[2, 3]>
//   Head=2, Tail=[3]
//   recurse → Length<[3]>

// step 2: Length<[3]>
//   Head=3, Tail=[]
//   recurse → Length<[]>

// step 3: Length<[]>
//   [] does NOT match [Head, ...Tail]  →  base case  →  0

// step 2 (unwinding): Length<[3]> = [1, ...0[]]['length'] = 1
// step 1 (unwinding): Length<[2, 3]> = [1, ...1[]]['length'] = 2
// step 0 (unwinding): Length<[1, 2, 3]> = [1, ...2[]]['length'] = 3

Compiler đi xuống tuple bóc từng head, chạm [], rồi bung ngược lên dựng kết quả. Mọi bài dưới đây là biến thể của làm gì với Head khi bung ngược.

Cạm bẫy

  • Thiếu base case — nếu nhánh : không bao giờ khớp [], TypeScript đệ quy tới giới hạn độ sâu rồi báo lỗi.
  • Tuple readonly[infer Head, ...infer Tail] cũng chạy trên tuple readonly, nhưng một số bài cần T extends readonly any[] trong ràng buộc.
  • Mảng không phải tupleany[] không có độ dài cố định có thể destructure không sạch; bài thường ràng buộc any[] hoặc readonly any[] và dựa vào tuple literal trong test.

Tóm tắt: T extends [infer Head, ...infer Tail] ? …đệ quy Tail… : base là vòng for ở tầng type.


3057 · Push & 3060 · Unshift

Mục tiêu

Nối một type vào cuối tuple, hoặc chèn vào đầu — bản type-level của Array.prototype.pushArray.prototype.unshift.

type A = Push<[1, 2], 3>;    // [1, 2, 3]
type B = Unshift<[1, 2], 0>; // [0, 1, 2]

Vì sao trông đơn giản nhưng đáng học

Hai bài này không cần đệ quy — nhưng là hạt cơ bản mọi lời giải đệ quy bên dưới dùng khi dựng accumulator hoặc tuple kết quả. Khi thấy [...Reverse<Tail>, Head] trong Reverse, spread đó chính là Push ngụy trang.

Cách thử ngây thơ

// ❌ naive — concatenation operator does not exist for types
type Push<T extends any[], V> = T + V; // syntax error

TypeScript không có + cho tuple; bạn phải dùng spread tuple.

Hiện đáp án
type Push<T extends any[], V> = [...T, V];
type Unshift<T extends any[], V> = [V, ...T];

Từng dòng

Push

type Push<T extends any[], V> = [...T, V];
//     ^^^^^^^^^^^^^^^^^^^^^^    ^^^^  ^
//     T must be tuple-shaped    spread  append V at the end
  • T là kiểu tuple (hoặc mảng).
  • spread mọi phần tử của T, rồi thêm V làm phần tử cuối.

Unshift

type Unshift<T extends any[], V> = [V, ...T];
//                                  ^   ^^^^
//                                  V first, then all of T
  • V đứng đầu; phần còn lại của T theo sau.

Đánh giá cụ thể

// Push<[1, 2], 3>
// step 0: T = [1, 2], V = 3
// step 1: spread T → 1, 2
// step 2: append V → [1, 2, 3]

// Unshift<[1, 2], 0>
// step 0: T = [1, 2], V = 0
// step 1: place V first → 0
// step 2: spread T after → [0, 1, 2]

Cạm bẫy

  • Bất biến — khác push runtime, đây trả về kiểu tuple mới; không có gì bị mutate.
  • Dùng trong đệ quyReverseAcc<R, [H, ...Acc]> mỗi bước Unshift H vào accumulator.

Tóm tắt: Push = [...T, V], Unshift = [V, ...T] — khối dựng cho mọi mẫu accumulator.


3192 · Reverse

Mục tiêu

Đảo thứ tự các phần tử của tuple — [1, 2, 3] thành [3, 2, 1].

type R = Reverse<['a', 'b', 'c']>; // ['c', 'b', 'a']

Cách thử ngây thơ

// ❌ naive — prepending Head keeps original order
type Reverse<T extends any[]> = T extends [infer Head, ...infer Tail]
  ? [Head, ...Reverse<Tail>]   // builds [first, second, third] — NOT reversed
  : [];

Bóc Head rồi đặt đầu giữ nguyên thứ tự; bạn cần ngược lại — xử lý đuôi trước, rồi nối head vào cuối.

Cơ chế

Bóc Head, đảo hoàn toàn Tail, rồi đẩy Head vào cuối đuôi đã đảo. Phần tử bóc cuối cùng thành phần tử đầu trong kết quả vì nó chờ ở cuối qua mọi tầng đệ quy.

Hiện đáp án
type Reverse<T extends any[]> = T extends [infer Head, ...infer Tail]
  ? [...Reverse<Tail>, Head]
  : [];

Từng dòng

type Reverse<T extends any[]> = T extends [infer Head, ...infer Tail]
//     ^^^^^^^^^^^^^^^^^^^^^^      ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
//     input tuple                 peel first element + rest

  ? [...Reverse<Tail>, Head]
//  ^^^^^^^^^^^^^^^^^^^^^^^^
//  recurse on Tail FIRST, spread that result, then append Head at the end

  : [];
//  ^^
//  base case: empty tuple → empty tuple
  1. Khớp patternT không rỗng thành Head (đầu) + Tail (phần còn lại).
  2. Nhánh đệ quyReverse<Tail> chạy trước khi đặt Head; spread [...] nối đuôi đã đảo hoàn toàn với Head ở cuối.
  3. Base case[] không khớp [Head, ...Tail], trả [].

Đánh giá cụ thể

// step 0: Reverse<[1, 2, 3]>
//   Head = 1, Tail = [2, 3]
//   need [...Reverse<[2, 3]>, 1]  — must finish Reverse<[2, 3]> first

// step 1: Reverse<[2, 3]>
//   Head = 2, Tail = [3]
//   need [...Reverse<[3]>, 2]

// step 2: Reverse<[3]>
//   Head = 3, Tail = []
//   need [...Reverse<[]>, 3]

// step 3: Reverse<[]>
//   [] does not match [Head, ...Tail]  →  base case  →  []

// step 2 (unwind): [...[], 3]           = [3]
// step 1 (unwind): [...[3], 2]          = [3, 2]
// step 0 (unwind): [...[3, 2], 1]       = [3, 2, 1]

Đặt Head sau kết quả đệ quy chính là thứ lật thứ tự — mỗi head chờ ở cuối trong khi các lời gọi trong giải quyết.

Cạm bẫy

  • Không đệ quy đuôi[...Reverse<Tail>, Head] bọc lời gọi đệ quy trong spread, nên TypeScript không tối ưu đệ quy đuôi; tuple dài có thể chạm giới hạn ~50.
  • Sửa bằng accumulatorReverseAcc<T, Acc> prepend mỗi Head vào Acc khi đi xuống, biến lời gọi thành đệ quy đuôi (xem mục giới hạn độ sâu bên dưới).

Tóm tắt: đảo đuôi trước, đẩy head sau — [...Reverse<Tail>, Head].


898 · Includes

Mục tiêu

Tuple có chứa một type cho trước không? Như Array.prototype.includes — nhưng ở tầng type, với thành viên chính xác, không phải assignability lỏng.

type A = Includes<[1, 2, 3], 2>;       // true
type B = Includes<[1, 2, 3], 4>;       // false
type C = Includes<[boolean], false>;   // false (not equal, just assignable)

Cách thử ngây thơ

// ❌ naive — extends checks assignability, not equality
type Includes<T extends readonly any[], U> = T extends [infer Head, ...infer Tail]
  ? Head extends U
    ? true
    : Includes<Tail, U>
  : false;

Với ca C, false extends booleantrue, nên cách này báo sai Includes<[boolean], false>true. Bài cần bằng chính xác.

Cơ chế

Duyệt tuple từng Head. Mỗi bước so Head với U bằng Equal chặt từ Phần 3. Bước nào khớp → true; hết tuple mà không khớp → false.

Hiện đáp án
type Equal<X, Y> =
  (<T>() => T extends X ? 1 : 2) extends (<T>() => T extends Y ? 1 : 2)
    ? true
    : false;

type Includes<T extends readonly any[], U> = T extends [infer Head, ...infer Tail]
  ? Equal<Head, U> extends true
    ? true
    : Includes<Tail, U>
  : false;

Từng dòng

Equal<X, Y> — kiểm tra assignability hai chiều qua tham số hàm contravariant; trả true chỉ khi XY cùng type, không chỉ assignable.

Includes

type Includes<T extends readonly any[], U> = T extends [infer Head, ...infer Tail]
//                                          peel one element

  ? Equal<Head, U> extends true
//  ^^^^^^^^^^^^^^^^^^^^^^^^^^^
//  strict equality check — not Head extends U

    ? true
//    ^^^^
//    found it — short-circuit, stop searching

    : Includes<Tail, U>
//    ^^^^^^^^^^^^^^^^^
//    not equal — keep searching in the rest

  : false;
//  ^^^^^
//  empty tuple — never found U
  1. Bóc Head ở đầu.
  2. nếu bằng chặt, trả true ngay (không cần xét đuôi).
  3. ngược lại đệ quy Tail.
  4. Tuple rỗng → false.

Đánh giá cụ thể

// step 0: Includes<[1, 2, 3], 2>
//   Head = 1, Tail = [2, 3]
//   Equal<1, 2> → false  →  recurse Includes<[2, 3], 2>

// step 1: Includes<[2, 3], 2>
//   Head = 2, Tail = [3]
//   Equal<2, 2> → true  →  short-circuit: true

// result: true

Đánh giá cụ thể

// step 0: Includes<[boolean], false>
//   Head = boolean, Tail = []
//   Equal<boolean, false> → false  (not equal — false is narrower)
//   recurse Includes<[], false>

// step 1: Includes<[], false>
//   [] → base case → false

// result: false  ✅ correct (naive Head extends U would have returned true)

Cạm bẫy

  • extends vs Equal — dùng extends khi ý là “assignable tới”; dùng Equal khi test đòi đúng identity type.
  • Thoát sớm — trả true khi khớp nghĩa là không quét phần còn lại; false chỉ khi hết cả tuple.
  • readonly — ràng buộc readonly any[] nhận cả input tuple mutable lẫn readonly.

Tóm tắt: bóc, Equal<Head, U>, short-circuit khi true, không thì đệ quy — không dùng extends thường cho thành viên chính xác.


459 · Flatten

Mục tiêu

Làm phẳng kiểu mảng lồng nhau sâu thành tuple một tầng — một chiều, mọi lá theo thứ tự.

type R = Flatten<[1, [2, [3, [4]], 5]]>; // [1, 2, 3, 4, 5]

Cách thử ngây thơ

// ❌ naive — only removes one level of nesting
type Flatten<T extends any[]> = T extends [infer Head, ...infer Tail]
  ? [...(Head extends any[] ? Head : [Head]), ...Flatten<Tail>]
  : [];

Cách này bóc Head một tầng nhưng không đệ quy vào Head khi nó còn lồng — [2, [3, [4]]] vẫn còn lồng một phần. Bạn cần đệ quy hai chiều: dọc danh sách (Tail) và vào từng phần tử (Head).

Cơ chế

Mỗi bước bóc Head và hỏi: Head có phải mảng không?

  • → làm phẳng Head đệ quy, làm phẳng Tail đệ quy, nối cả hai.
  • Không → giữ Head như lá, chỉ làm phẳng Tail.
Hiện đáp án
type Flatten<T extends any[]> = T extends [infer Head, ...infer Tail]
  ? Head extends any[]
    ? [...Flatten<Head>, ...Flatten<Tail>]
    : [Head, ...Flatten<Tail>]
  : [];

Từng dòng

type Flatten<T extends any[]> = T extends [infer Head, ...infer Tail]
//                                peel Head + Tail

  ? Head extends any[]
//  ^^^^^^^^^^^^^^^^^^
//  branch: is Head nested?

    ? [...Flatten<Head>, ...Flatten<Tail>]
//    ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
//    Head is array → flatten BOTH Head and Tail, concat

    : [Head, ...Flatten<Tail>]
//    ^^^^^^^^^^^^^^^^^^^^^^^^
//    Head is leaf → keep Head, flatten only Tail

  : [];
//  base case: empty → []
  1. Đệ quy ngoàiT extends [infer Head, ...infer Tail] duyệt danh sách tầng trên.
  2. Đệ quy trongHead extends any[] quyết định khoan vào Head hay coi là lá.
  3. Nối — spread (...) lắp lại các mảnh đã phẳng theo thứ tự.
  4. Base case[][].

Đánh giá cụ thể

// step 0: Flatten<[1, [2, 3]]>
//   Head = 1, Tail = [[2, 3]]
//   1 is NOT an array  →  [1, ...Flatten<[[2, 3]]>]

// step 1: Flatten<[[2, 3]]>
//   Head = [2, 3], Tail = []
//   [2, 3] IS an array  →  [...Flatten<[2, 3]>, ...Flatten<[]>]

// step 2: Flatten<[2, 3]>
//   Head = 2, Tail = [3]
//   2 is NOT an array  →  [2, ...Flatten<[3]>]

// step 3: Flatten<[3]>
//   Head = 3, Tail = []
//   3 is NOT an array  →  [3, ...Flatten<[]>]

// step 4: Flatten<[]>
//   base case  →  []

// unwind step 3: [3, ...[]]           = [3]
// unwind step 2: [2, ...[3]]          = [2, 3]
// unwind step 1: [...[2, 3], ...[]]   = [2, 3]
// unwind step 0: [1, ...[2, 3]]       = [1, 2, 3]

Cạm bẫy

  • Đệ quy hai chiều — giới hạn độ sâu áp cho cả độ dài danh sách lẫn độ sâu lồng; lồng rất sâu có thể lỗi dù tuple ngắn.
  • any[] vs tupleHead extends any[] khớp cả [2, 3] lẫn Array<number>; bài coi mọi kiểu mảng là có thể lồng.
  • Viết lại đệ quy đuôi — bài nâng cao cuối dùng accumulator để chịu lồng sâu hơn.

Tóm tắt: mỗi head, rẽ nhánh “có phải mảng?” — làm phẳng vào trong hoặc giữ làm lá, luôn đệ quy đuôi.


5310 · Join

Mục tiêu

Nối một tuple string/số thành một string, ngăn bởi U — như Array.prototype.join.

type A = Join<['a', 'p', 'p', 'l', 'e'], '-'>; // 'a-p-p-l-e'
type B = Join<['你', '好'], 1>;                 // '你1好'

Cách thử ngây thơ

// ❌ naive — always appends separator, leaving a trailing U
type Join<T extends (string | number)[], U extends string | number> =
  T extends [infer Head, ...infer Tail]
    ? `${Head}${U}${Join<Tail, U>}`
    : '';
// Join<['a', 'b'], '-'> → 'a-b-'  ← trailing separator

Chèn U sau mọi Head tạo dấu ngăn thừa ở phần tử cuối. Bạn cần phát hiện “đây có phải phần tử cuối?”.

Cơ chế

Bóc Head, rồi kiểm tra Tail rỗng qua Tail['length'] extends 0.

  • Phần tử cuối (Tail[]) → chỉ phát `${Head}`, không dấu ngăn.
  • Chưa cuối → phát `${Head}${U}${Join<Tail, U>}`.

Dùng infer có ràng buộc (infer Head extends string | number) để Head hợp lệ trong template literal.

Hiện đáp án
type Join<
  T extends (string | number)[],
  U extends string | number,
> = T extends [
  infer Head extends string | number,
  ...infer Tail extends (string | number)[],
]
  ? Tail['length'] extends 0
    ? `${Head}`
    : `${Head}${U}${Join<Tail, U>}`
  : '';

Từng dòng

type Join<T extends (string | number)[], U extends string | number> =
//     ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^    ^^^^^^^^^^^^^^^^^^^^^^^^
//     elements must be string | number      separator too

  T extends [
    infer Head extends string | number,
//  ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
//  constrained infer — Head is usable in `${Head}`

    ...infer Tail extends (string | number)[],
//  ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
//  rest of tuple, also constrained
  ]

  ? Tail['length'] extends 0
//  ^^^^^^^^^^^^^^^^^^^^^^^^
//  is Tail empty? → Head is the LAST element

    ? `${Head}`
//    ^^^^^^^^^
//    last element: no separator after

    : `${Head}${U}${Join<Tail, U>}`
//    ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
//    not last: emit Head + separator + recurse

  : '';
//  ^^
//  empty tuple → empty string
  1. infer có ràng buộc — không có extends string | number, Head có thể infer quá rộng cho template literal.
  2. Tail['length'] extends 0 — độ dài tuple là literal type số; [] có length 0.
  3. Nhánh phần tử cuối — chỉ `${Head}`, tránh U thừa cuối.
  4. Nhánh đệ quy — dán Head, dấu ngăn U, và phần còn lại.
  5. Base case — tuple rỗng → ''.

Đánh giá cụ thể

// step 0: Join<['a', 'b', 'c'], '-'>
//   Head = 'a', Tail = ['b', 'c']
//   Tail['length'] = 2, not 0  →  `a-${Join<['b', 'c'], '-'>}`

// step 1: Join<['b', 'c'], '-'>
//   Head = 'b', Tail = ['c']
//   Tail['length'] = 1, not 0  →  `b-${Join<['c'], '-'>}`

// step 2: Join<['c'], '-'>
//   Head = 'c', Tail = []
//   Tail['length'] = 0  →  last element  →  `c`

// unwind step 1: `b-c`
// unwind step 0: `a-b-c`

Đánh giá cụ thể

// step 0: Join<['你', '好'], 1>
//   Head = '你', Tail = ['好']
//   Tail['length'] = 1, not 0  →  `你1${Join<['好'], 1>}`

// step 1: Join<['好'], 1>
//   Head = '好', Tail = []
//   Tail['length'] = 0  →  `好`

// unwind: `你1好`

Cạm bẫy

  • Tail['length'] extends 0 vs Tail extends [] — cả hai phát hiện đuôi rỗng; length là style type-challenges quen thuộc.
  • Kiểu dấu ngănU extends string | number cho phép dấu ngăn số như 1 trong ví dụ apple.
  • Tuple một phần tửJoin<['x'], '-']> vào nhánh cuối ngay → 'x', không phải 'x-'.

Tóm tắt: bóc Head, kiểm Tail['length'] extends 0 cho phần tử cuối, không thì `${Head}${U}${Join<Tail, U>}`.


Ghi chú về giới hạn độ sâu đệ quy

Mục tiêu

Hiểu vì sao tuple dài đôi khi ra Type instantiation is excessively deep and possibly infinite — và mẫu accumulator sửa thế nào.

Vì sao có giới hạn

TypeScript giới hạn độ sâu khởi tạo type để chặn bung vô hạn từ type đệ quy lỗi. Trước đây giới hạn ~50 tầng; conditional type đệ quy đuôi có thể sâu hơn nhiều (~1000) vì compiler có thể gộp các lời gọi đệ quy liên tiếp giống nhau.

Không đệ quy đuôi vs đệ quy đuôi

// ❌ not tail-recursive: the recursive call is WRAPPED by [...Reverse<R>, H]
//    TypeScript must fully resolve Reverse<R> before it can spread + append H
type Reverse<T extends any[]> = T extends [infer H, ...infer R]
  ? [...Reverse<R>, H]
  : [];

// ✅ tail-recursive: the recursive call IS the entire result — nothing wraps it
type ReverseAcc<T extends any[], Acc extends any[] = []> = T extends [
  infer H,
  ...infer R,
]
  ? ReverseAcc<R, [H, ...Acc]>   // prepend H to accumulator, recurse
  : Acc;                          // base case: return accumulator directly

Trong Reverse, mỗi tầng chờ kết quả trong, rồi bọc trong spread — đó không phải đệ quy đuôi. Trong ReverseAcc, lời gọi ReverseAcc<R, [H, ...Acc]>toàn bộ đáp án của nhánh ?; không có constructor ngoài bọc nó.

Đánh giá cụ thể

// step 0: ReverseAcc<['a', 'b', 'c'], []>
//   H='a', R=['b','c'], Acc=[]  →  ReverseAcc<['b','c'], ['a']>

// step 1: ReverseAcc<['b', 'c'], ['a']>
//   H='b', R=['c'], Acc=['a']  →  ReverseAcc<['c'], ['b', 'a']>

// step 2: ReverseAcc<['c'], ['b', 'a']>
//   H='c', R=[], Acc=['b','a']  →  ReverseAcc<[], ['c', 'b', 'a']>

// step 3: ReverseAcc<[], ['c', 'b', 'a']>
//   [] → base case → Acc = ['c', 'b', 'a']

Đáp án được dựng hoàn toàn trong Acc khi base case chạy — return là tra cứu trực tiếp, không phải chuỗi spread lồng nhau.

Cạm bẫy

  • Thứ tự accumulatorReverseAcc prepend ([H, ...Acc]), nên Acc lớn dần theo thứ tự đảo và đã đúng ở base case.
  • Không phải bài nào cũng đệ quy đuôi dễFlatten cần accumulator phức tạp hơn (xem bài nâng cao) vì đệ quy hai chiều.
  • Xem trước Phần 8 — số học tầng type ở Phần 8 dựa nhiều vào idiom accumulator này.

Tóm tắt: bọc lời gọi đệ quy → đau giới hạn độ sâu; để lời gọi đệ quy là việc cuối với accumulator → input sâu hơn nhiều.


Bài tập

1. Cài Without<T, U> xoá khỏi tuple T mọi phần tử có trong U (một giá trị hoặc tuple giá trị).

Lời giải
type ToUnion<U> = U extends any[] ? U[number] : U;

type Without<T extends any[], U> = T extends [infer H, ...infer R]
  ? H extends ToUnion<U>
    ? Without<R, U>
    : [H, ...Without<R, U>]
  : [];

Cơ chế: chuẩn hoá U thành union qua ToUnion, rồi với mỗi Head hoặc bỏ (khi H extends ToUnion<U>) hoặc giữ và đệ quy.

// Without<[1, 2, 3, 4], [2, 4]>
// step 0: H=1, 1 extends 2|4? no  →  [1, ...Without<[2,3,4], [2,4]>]
// step 1: H=2, 2 extends 2|4? yes →  skip  →  Without<[3,4], [2,4]>
// step 2: H=3, 3 extends 2|4? no  →  [3, ...Without<[4], [2,4]>]
// step 3: H=4, 4 extends 2|4? yes →  skip  →  Without<[], [2,4]> → []
// unwind: [1, 3]

2. Vì sao Includes cần Equal còn Without ở trên dùng extends cũng được?

Lời giải

Test của Without dùng literal khác biệt nên extendsEqual cho cùng kết quả — xoá “mọi thứ assignable tới 2 | 4” là ngữ nghĩa đúng. Includes có ca ([boolean] vs false) mà assignable ≠ equal: false extends booleantrue, nhưng Equal<boolean, false>false. Luôn đọc test để biết bài cần thành viên (chính xác) hay lọc (assignable).

Nâng cao:viết lại Flatten kiểu đệ quy đuôi với accumulator để chịu được lồng rất sâu.


Điểm chính

  • Khung lặp là T extends [infer Head, ...infer Tail] ? …đệ quy Tail… : base.
  • Luôn chốt làm gì với Headbase case trước khi viết logic.
  • Dùng Equal (không extends) khi bài cần thành viên chính xác.
  • Flatten đệ quy hai chiều — vào Head và dọc Tail.
  • Với đầu vào dài, làm đệ quy đệ quy đuôi với accumulator để né giới hạn độ sâu.

Tiếp theo

Phần 6 — Thao tác Union: union không phải tuple, không index theo vị trí được. Ta biến string thành union, đếm và rút phần tử union, và dựng UnionToTuple nổi tiếng bằng đồ nghề contravariance từ Phần 1.