jvinhit//lab

Search posts

Type to search across journal entries.

navigate open esc close

TS Type Challenges · Part 11 — Extreme Challenges

Part 11: the hardest entries, taken slowly. Build Currying, a dotted-path Get, and IsPalindrome by composing patterns from the whole series — proof that extreme types are just familiar tricks stacked. With toggle-to-reveal answers.

Đây là Phần 11 của series 12 bài về TypeScript type challenges. ""Cực khó” nghe đáng sợ, nhưng đây là bí mật: bài cực khó không thêm primitive mới nào. Chúng chỉ xếp chồng các mẫu bạn đã có — đệ quy, infer, variadic tuple, khớp template, distribution — sâu hơn một chút. Ta sẽ chứng minh trên ba bài kinh điển.

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


17 · Currying

Biến một type hàm nhiều tham số thành chuỗi các hàm một tham số.

declare function Currying<F>(fn: F): Curry<F>;

const c = Currying((a: number, b: string, c: boolean) => true);
// type of c: (a: number) => (b: string) => (c: boolean) => boolean

Mục tiêu và vì sao khó

Lúc chạy, currying là “gọi một tham số, nhận lại hàm chờ các tham số còn lại”. Ở cấp type, bạn phải tạo ra type hàm lồng nhau từ chữ ký phẳng (a, b, c) => R — mà không chạy gì cả. Trình biên dịch không có vòng for trên tuple; bạn bóc tham số từng cái một bằng đệ quy, và mỗi lần bóc phải sinh đúng một lớp hàm mới.

Cách làm ngây thơ

Bạn có thể thử map hết tham số một lần rồi nối các lớp bằng tay:

// ❌ wishful thinking — TypeScript has no "map tuple → nested =>"
type NaiveCurry<F> = F extends (...args: infer Args) => infer R
  ? Args extends [infer A, infer B, infer C] // fixed arity only!
    ? (a: A) => (b: B) => (c: C) => R
    : never
  : never;

Cách này cứng ba tham số. Hàm bốn tham số thất bại; hàm không tham số thất bại; hàm có rest parameter thất bại. Bạn cần bóc variadic — cùng chiêu đệ quy tuple từ Phần 5.

Hiện đáp án
type Curry<F> = F extends (...args: infer Args) => infer R
  ? Args extends [infer First, ...infer Rest]
    ? Rest extends []
      ? (arg: First) => R
      : (arg: First) => Curry<(...rest: Rest) => R>
    : () => R
  : never;

Các cơ chế kết hợp

PieceVai trò
F extends (...args: infer Args) => infer RTrích tuple tham số + return type (chiêu Parameters / ReturnType từ Phần 4)
Args extends [infer First, ...infer Rest]Bóc tham số đầu, giữ phần đuôi dạng tuple
Rest extends []Nhận biết “đây là tham số cuối”
Curry<(...rest: Rest) => R>Dựng lại type hàm nhỏ hơn rồi đệ quy trên nó

Khối A — bắt hình dạng hàm

type Curry<F> = F extends (...args: infer Args) => infer R
//              ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
//              conditional + infer: only functions match;
//              Args becomes [number, string, boolean], R becomes boolean

Nếu F không phải type gọi được, toàn bộ thành never. Từ khóa infer gán tên chỉ trong nhánh này — ArgsR tồn tại cho phần còn lại của nhánh ?.

Khối B — bóc một tham số

  ? Args extends [infer First, ...infer Rest]
//  ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
//  variadic tuple pattern: First = head, Rest = tail tuple

Với Args = [number, string, boolean]: First = number, Rest = [string, boolean]. Đây là cùng cách tách “đầu/đuôi” khi duyệt mảng ở Phần 5.

Khối C — ba nhánh thoát

    ? Rest extends []
      ? (arg: First) => R                           // last param → terminal function
      : (arg: First) => Curry<(...rest: Rest) => R> // more params → recurse
    : () => R                                       // zero params → nullary function
  • Rest extends []: đuôi rỗng, nên First là tham số duy nhất còn lại. Trả (arg: First) => R — một hàm, không lồng thêm.
  • Rest còn phần tử: trả (arg: First) => Curry<(...rest: Rest) => R>. Hàm trong (...rest: Rest) => Rtype hàm mới với ít tham số hơn — Curry chạy lại trên hàm nhỏ hơn đó.
  • Args không khớp [infer First, ...infer Rest]: danh sách tham số rỗng ([]). Trả () => R.

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

Đầu vào: F = (a: number, b: string) => boolean.

// step 1: infer from F
//   Args = [number, string]
//   R    = boolean

type Step1 = Curry<(a: number, b: string) => boolean>;
// expands to:
//   Args = [number, string]
//   First = number, Rest = [string]
//   Rest extends [] ? NO
//   → (arg: number) => Curry<(rest: string) => boolean>

// step 2: inner Curry on (rest: string) => boolean
//   Args = [string]
//   First = string, Rest = []
//   Rest extends [] ? YES
//   → (arg: string) => boolean

// step 3: plug back
type Result = (arg: number) => (arg: string) => boolean;
// ✅ matches the curried shape

Theo dõi ví dụ ba tham số trong đề bài theo cùng cách — một bước đệ quy cho mỗi tham số:

// F = (a: number, b: string, c: boolean) => boolean

// step 1: peel number → Curry<(b: string, c: boolean) => boolean>
// step 2: peel string → Curry<(c: boolean) => boolean>
// step 3: peel boolean, Rest = [] → (arg: boolean) => boolean
// final: (a: number) => (b: string) => (c: boolean) => boolean

Cạm bẫy

  • Hàm generic: Curry giữ return type R nhưng không curry lại type parameter generic ở lớp ngoài — đủ cho bài challenge, nhưng curry thật của <T>(x: T) => … cần cẩn thận thêm.
  • Rest parameter (...args: number[]): Args thành number[], không khớp [infer First, ...infer Rest] như mong đợi — lời giải này nhắm tuple arity cố định.
  • Độ sâu đệ quy: hàm với hàng chục tham số lồng Curry hàng chục lần; giới hạn độ sâu khởi tạo của TypeScript (~50 bước tùy phiên bản) có thể cắn arity rất dài.
  • Tham số optional / union: tham số optional xuất hiện trong ArgsT | undefined; currying vẫn chạy, nhưng hàm đã curry sẽ yêu cầu slot đó tường minh.

Tóm tắt: Curry = infer Curry = infer args, bóc một, hoặc kết thúc hoặc đệ quy trên type hàm nhỏ hơn.


270 · Typed Get

Truy cập object lồng nhau bằng một chuỗi đường dẫn có dấu chấm, như get của lodash.

type Data = { foo: { bar: { value: 'hello' }; count: 6 } };
type A = Get<Data, 'foo.bar.value'>; // 'hello'
type B = Get<Data, 'foo.count'>;     // 6
type C = Get<Data, 'foo.bar.xyz'>;   // never (invalid path)

Mục tiêu và vì sao khó

get(obj, 'foo.bar.value') lúc chạy parse chuỗi đường dẫn rồi đi bộ object. Bản type phải đi bộ tương tự trên type — và trả never khi bất kỳ đoạn nào không hợp lệ. Phần khó: (1) tách 'foo.bar.value' thành các đoạn lúc biên dịch, (2) chứng minh mỗi đoạn là key hợp lệ ở độ sâu đó, (3) hỗ trợ độ dài đường dẫn biến đổi mà không cứng độ sâu.

Cách làm ngây thơ

Truy cập một cấp thì dễ:

type ShallowGet<T, K extends keyof T> = T[K]; // only one key, no dots

Tách toàn bộ đường dẫn trước bằng utility Split rồi index bằng tuple key cố định đòi biết trước độ sâu tối đa:

// ❌ awkward — needs a separate "walk N keys" for each depth
type Split<S, Sep> = /* ... from Part 7 ... */;
type NaiveGet<T, Path> = T[Split<Path, '.'>[0]][Split<Path, '.'>[1]]; // depth = 2 only

Cách sửa: đệ quy xuống — tách một dấu chấm mỗi lần, xuống một cấp, lặp lại. Đó là parser; bạn đã làm tách template ở Phần 7 và indexed access ở Phần 1.

Hiện đáp án
type Get<T, K extends string> = K extends `${infer Head}.${infer Rest}`
  ? Head extends keyof T
    ? Get<T[Head], Rest>
    : never
  : K extends keyof T
    ? T[K]
    : never;

Các cơ chế kết hợp

PieceVai trò
K extends `${infer Head}.${infer Rest}`Tách ở dấu chấm đầu tiên (khớp template tham lam-trái từ Phần 7)
Head extends keyof TKiểm tra key type-safe trước khi xuống
Get<T[Head], Rest>Đệ quy vào type thuộc tính với chuỗi đường dẫn còn lại
K extends keyof T ? T[K] : neverTrường hợp cơ sở: không còn dấu chấm → tra cứu cuối

Khối A — đường dẫn có dấu chấm? đệ quy

type Get<T, K extends string> = K extends `${infer Head}.${infer Rest}`
//                              ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
//  'foo.bar.value' → Head='foo', Rest='bar.value'
//  'foo.count'     → Head='foo', Rest='count'

Suy luận template tham lam trái sang phải: dấu chấm đầu tiên thắng. Rest giữ dấu chấm — mỗi bước đệ quy chỉ tiêu thụ một đoạn.

Khối B — kiểm tra key, rồi xuống

  ? Head extends keyof T
    ? Get<T[Head], Rest>
    : never

keyof T là union các key ở cấp object hiện tại. Nếu Head không phải key ở đây, đường dẫn không hợp lệ → never. T[Head] là indexed access — bạn bước vào type lồng nhau.

Khối C — trường hợp cơ sở: đoạn cuối

  : K extends keyof T
    ? T[K]
    : never;

Khi K không có dấu chấm, nhánh đầu thất bại và bạn rơi vào đây. 'count' trên { foo: …; count: 6 }6. 'xyz' trên { value: 'hello' }never.

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

Get<Data, 'foo.bar.value'> với Data = { foo: { bar: { value: 'hello' }; count: 6 } }.

// step 1: K = 'foo.bar.value' — has a dot
//   Head = 'foo', Rest = 'bar.value'
//   'foo' extends keyof Data ? YES (keyof Data = 'foo')
//   → Get<Data['foo'], 'bar.value'>
//   → Get<{ bar: { value: 'hello' }; count: 6 }, 'bar.value'>

// step 2: K = 'bar.value' — has a dot
//   Head = 'bar', Rest = 'value'
//   'bar' extends keyof { bar: …; count: 6 } ? YES
//   → Get<{ value: 'hello' }, 'value'>

// step 3: K = 'value' — NO dot (base case)
//   'value' extends keyof { value: 'hello' } ? YES
//   → { value: 'hello' }['value']
//   → 'hello'  ✅

Đường dẫn không hợp lệ Get<Data, 'foo.bar.xyz'>:

// steps 1–2 same as above → Get<{ value: 'hello' }, 'xyz'>
// step 3: 'xyz' extends keyof { value: 'hello' } ? NO
//   → never  ✅

Đường dẫn ngắn hơn Get<Data, 'foo.count'>:

// step 1: Head='foo', Rest='count'
//   → Get<{ bar: …; count: 6 }, 'count'>
// step 2: 'count' has no dot → count extends keyof … ? YES → 6  ✅

Cạm bẫy

  • Key chứa dấu chấm: nếu key thật là 'a.b', parser này không phân biệt được với đường dẫn ab. lodash get lúc chạy cũng mơ hồ tương tự; Get typed giả định đường dẫn tách bằng dấu chấm thông thường.
  • Union path phân phối: Get<Data, 'foo.bar.value' | 'foo.count'> thành 'hello' | 6Get là conditional type trên K.
  • Thuộc tính optional / undefined: Get<T, path> trả type thuộc tính nguyên trạng, kể cả undefined nếu key optional — không thu hẹp giá trị thiếu lúc chạy.
  • Mảng và đoạn số: '0' đoạn '0' chỉ chạy nếu '0' nằm trong keyof T (hiếm với tuple); index mảng cần mẫu bài khác.
  • Độ sâu đệ quy: đường dẫn 'a.b.c.d.…' với 50+ đoạn có thể chạm giới hạn khởi tạo; lời khuyên đệ quy đuôi như Phần 5 áp dụng cho utility production.

Tóm tắt: Get = Get = tách một dấu chấm, kiểm tra keyof, xuống bằng T[Head], lặp đến khi hết dấu chấm.


4037 · IsPalindrome

Một string hay số có phải palindrome không?

type A = IsPalindrome<'abcba'>; // true
type B = IsPalindrome<121>;     // true
type C = IsPalindrome<'abc'>;   // false

Mục tiêu và vì sao khó

Palindrome đọc giống nhau xuôi và ngược. TypeScript không có string[0], string.length, hay reverse() ở cấp type. Bạn phải (1) nhận cả đầu vào stringnumber, (2) duyệt ký tự bằng cách nào đó, (3) đảo chúng, (4) so bằng chính xác — và chỉ extends thì quá lỏng với tuple. Bài này là bài thi tổ hợp: bốn kỹ thuật từ bốn phần trước, ráp lại.

Cách làm ngây thơ

So sánh string với template literal đảo?

// ❌ no built-in way to reverse a template literal type
type NaivePalindrome<S extends string> = S extends `${infer R}${infer _}`
  ? S extends `${_}${infer R}` // wrong logic, and infer can't "reverse" strings
    ? true
    : false
  : true;

Không có reverse(string) ở cấp type — đảo vẫn nghĩa là bóc ký tự vào tuple rồi dựng lại. So bằng extends hai chiều thất bại với bằng tuple ([1,2] extends [1,2] đúng, nhưng vấn đề width/subtyping rình rập). Cần “đồ nghề” Equal chặt từ Phần 3.

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

type StringToTuple<S extends string> = S extends `${infer Head}${infer Tail}`
  ? [Head, ...StringToTuple<Tail>]
  : [];

type Reverse<T extends any[]> = T extends [infer Head, ...infer Tail]
  ? [...Reverse<Tail>, Head]
  : [];

type IsPalindrome<T extends string | number> = Equal<
  StringToTuple<`${T}`>,
  Reverse<StringToTuple<`${T}`>>
>;

Các cơ chế kết hợp

HelperFromViệc
`${T}`Part 7Chuẩn hoá number → string (121'121')
StringToTuplePart 7'abc'['a','b','c'] 'abc'['a','b','c']
ReversePart 5['a','b','c']['c','b','a'] ['a','b','c']['c','b','a']
EqualPart 3Chỉ true khi tuple giống hệt hai chiều

Khối A — Equal: đồng nhất chặt

type Equal<X, Y> =
  (<T>() => T extends X ? 1 : 2) extends (<T>() => T extends Y ? 1 : 2)
    ? true : false;

Cả hai vế là type hàm được lượng hoá phổ biến. TypeScript so chúng theo cách loại tuple “gần bằng” và bắt edge case phân phối — công thức cộng đồng chuẩn cho đồng nhất type. Để kiểm tra palindrome, cần độ chặt này: ['a','b','a'] với ['a','b','c'] phải là false.

Khối B — StringToTuple: bóc ký tự

type StringToTuple<S extends string> = S extends `${infer Head}${infer Tail}`
  ? [Head, ...StringToTuple<Tail>]
  : [];

Mỗi bước lấy một ký tự Head rồi đệ quy trên Tail. '' chạm trường hợp cơ sở []. 'aba'

// 'aba' → ['a', ...StringToTuple<'ba'>]
//       → ['a', 'b', ...StringToTuple<'a'>]
//       → ['a', 'b', 'a', ...StringToTuple<''>]
//       → ['a', 'b', 'a']

Khối C — Reverse: đệ quy tuple

type Reverse<T extends any[]> = T extends [infer Head, ...infer Tail]
  ? [...Reverse<Tail>, Head]
  : [];

Đảo “linked list” cổ điển ở cấp type: giải đuôi, nối đầu. ['a','b','a'][...Reverse<['b','a']>, 'a'][...Reverse<['a']>, 'b', 'a']['a','b','a'][...Reverse<['b','a']>, 'a'][...Reverse<['a']>, 'b', 'a']['a','b','a'].

Khối D — IsPalindrome: ghép

type IsPalindrome<T extends string | number> = Equal<
  StringToTuple<`${T}`>,
  Reverse<StringToTuple<`${T}`>>
>;

Pipeline: chuẩn hoá → tuple → đảo → so chặt. Số như 121 thành '121' trước, nên string và number dùng chung một luồng.

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

IsPalindrome<'aba'> (palindrome nhỏ nhất không tầm thường):

// step 1: `${'aba'}` → 'aba' (already string)

// step 2: StringToTuple<'aba'>
//   → ['a', 'b', 'a']

// step 3: Reverse<['a', 'b', 'a']>
//   → [...Reverse<['b', 'a']>, 'a']
//   → [...Reverse<['a']>, 'b', 'a']
//   → [...[], 'a', 'b', 'a']
//   → ['a', 'b', 'a']

// step 4: Equal<['a','b','a'], ['a','b','a']> → true  ✅

IsPalindrome<'abc'> (không phải palindrome):

// step 2: StringToTuple<'abc'> → ['a', 'b', 'c']
// step 3: Reverse<['a','b','c']> → ['c', 'b', 'a']
// step 4: Equal<['a','b','c'], ['c','b','a']> → false  ✅

IsPalindrome<121> (đầu vào số):

// step 1: `${121}` → '121'   (template literal coercion)

// step 2: StringToTuple<'121'> → ['1', '2', '1']
// step 3: Reverse → ['1', '2', '1']
// step 4: Equal → true  ✅

Cạm bẫy

  • Tính StringToTuple hai lần: composition gọi StringToTuple<`${T}`> hai lần (mỗi vế Equal một lần). Trong code thật, nên alias type trung gian: type Chars = StringToTuple<`${T}`>; type IsPalindrome<T> = Equal<Chars, Reverse<Chars>>.
  • Độ sâu đệ quy: 'a'.repeat(60) làm vỡ giới hạn khởi tạo StringToTuple + Reverse. Đầu vào dài cần dạng đảo accumulator từ Phần 5.
  • Số âm: `${-121}` `${-121}` thành '-121', không phải palindrome dạng chuỗi ('1-2-1' đảo) dù khái niệm palindrome số có thể khác. Bài challenge theo quy tắc ép kiểu chuỗi.
  • Equalany: nếu đầu vào nới thành any, Equal có thể lạ — giữ T extends string | number.
  • Grapheme Unicode: StringToTuple tách đơn vị mã UTF-16, không phải grapheme người dùng thấy (emoji, dấu kết hợp) — cùng hạn chế hầu hết bài string TS.

Tóm tắt: ép về string, tuple hoá, đảo tuple, Equal — bốn phần, một pipeline.

Nhìn danh sách “import”: bốn phần khác nhau của series, ráp lại với nhau. Đó là toàn bộ bài học của bài “cực khó” — chúng là bài thi tổ hợp, không phải bài thi kiến thức mới.


Cách công phá một bài cực khó

Khi một bài trông bất khả thi, hãy phân rã nó:

  1. Đặt tên các bài con. “Mỗi bài con là một bài bạn đã giải.
  2. Dựng helper riêng, test từng cái độc lập, rồi ghép.
  3. Để ý độ sâu đệ quy. Bài cực khó với đầu vào dài thường cần dạng accumulator đệ quy đuôi từ Phần 5 để né giới hạn khởi tạo.
  4. Dùng alias trung gian, không phải một biểu thức khổng lồ — chúng giúp debug bằng hover.

Phân rã có ví dụ: Get

Bài conĐã giải ở
Split string at delimiterPart 7`${infer H}.${infer R}`
Prove key existsPart 1K extends keyof T
Walk nested typeIndexed access T[K] + recursion from Part 5
Signal invalid pathnever (or undefined in the exercise variant)

Không dòng nào mới; chỉ thứ tự lắp ráp là mới.


Bài tập

1. Mở rộng Get để trả undefined (thay vì never) khi thiếu key, mô hình hoá một get lúc chạy có thể thất bại.

Lời giải
type GetOr<T, K extends string> = K extends `${infer Head}.${infer Rest}`
  ? Head extends keyof T
    ? GetOr<T[Head], Rest>
    : undefined
  : K extends keyof T
    ? T[K]
    : undefined;

Chỉ các nhánh thất bại đổi từ never sang undefined — logic duyệt giữ nguyên.

Vì sao undefined thay vì never? Lúc chạy, get(obj, 'missing') trả undefined, không crash — mô hình hoá trong type giúp code dùng phân biệt “tìm thấy T[K]” với “miss path” bằng kiểm tra Result extends undefined.

2. Vì sao IsPalindrome so sánh tuple thay vì so sánh string gốc với string đảo trực tiếp?

Lời giải

Bạn có thể dựng ReverseString<S> nối ký tự lại — nhưng dựng nó vẫn phải duyệt S vào tuple trước, rồi nối bằng đệ quy template. So sánh tuple là điểm dừng tự nhiên: khi ký tự đã theo vị trí, Reverse gọn một dòng và Equal cho kết quả chính xác. So string bằng extends hai chiều yếu hơn và hỏng ở lệch width tinh vi.

// weaker string compare — avoid for equality
type LooseEqual<S1 extends string, S2 extends string> =
  S1 extends S2 ? (S2 extends S1 ? true : false) : false;
// Equal<['a','b'], ['a','b']> is the reliable path for palindrome checks

Nâng cao:cài Set<T, Path, V> — bản ghi đối ứng với Get — trả object type mới với value tại Path thay bằng V, tạo object trung gian khi cần.


Điểm chính

  • Bài cực khó không thêm primitive mới — chúng ghép các mẫu quen thuộc sâu hơn.
  • Currying = infer tham số + bóc tuple + đệ quy từng bước.
  • Get = tách template + indexed access + đệ quy xuống.
  • IsPalindrome = string→tuple + đảo + Equal — bốn phần trong một dòng.
  • Công phá bài khó bằng cách đặt tên bài con, dựng helper, rồi ghép.

Tiếp theo

Phần 12 — Capstone: ta biến cả series thành một utility nhỏ, thực, gõ type đầy đủ, rồi bàn các kỹ năng meta — debug type, các giới hạn đệ quy/hiệu năng cần tôn trọng, và một kế hoạch luyện tập kèm cheat sheet một trang mọi mẫu đã dựng.