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
| Piece | Vai trò |
|---|---|
F extends (...args: infer Args) => infer R | Trí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 booleanNế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 — Args và R 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 tupleVớ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 functionRest extends []: đuôi rỗng, nênFirstlà tham số duy nhất còn lại. Trả(arg: First) => R— một hàm, không lồng thêm.Restcòn phần tử: trả(arg: First) => Curry<(...rest: Rest) => R>. Hàm trong(...rest: Rest) => Rlà type hàm mới với ít tham số hơn —Currychạy lại trên hàm nhỏ hơn đó.Argskhô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 shapeTheo 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) => booleanCạm bẫy
- Hàm generic:
Currygiữ return typeRnhư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[]):Argsthànhnumber[], 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
Curryhà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
ArgslàT | 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
| Piece | Vai 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 T | Kiể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] : never | Trườ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>
: neverkeyof 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ẫna→b. lodashgetlúc chạy cũng mơ hồ tương tự;Gettyped 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' | 6vìGetlà conditional type trênK. - Thuộc tính optional /
undefined:Get<T, path>trả type thuộc tính nguyên trạng, kể cảundefinednế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 trongkeyof 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 string và number, (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
| Helper | From | Việc |
|---|---|---|
`${T}` | Part 7 | Chuẩn hoá number → string (121 → '121') |
StringToTuple | Part 7 | 'abc' → ['a','b','c'] 'abc' → ['a','b','c'] |
Reverse | Part 5 | ['a','b','c'] → ['c','b','a'] ['a','b','c'] → ['c','b','a'] |
Equal | Part 3 | Chỉ 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
StringToTuplehai lần: composition gọiStringToTuple<`${T}`>hai lần (mỗi vếEqualmộ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ạoStringToTuple+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. Equalvàany: nếu đầu vào nới thànhany,Equalcó thể lạ — giữT extends string | number.- Grapheme Unicode:
StringToTupletá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ó:
- Đặt tên các bài con. “Mỗi bài con là một bài bạn đã giải.
- Dựng helper riêng, test từng cái độc lập, rồi ghép.
- Để ý độ 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.
- 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 delimiter | Part 7 — `${infer H}.${infer R}` |
| Prove key exists | Part 1 — K extends keyof T |
| Walk nested type | Indexed access T[K] + recursion from Part 5 |
| Signal invalid path | never (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 checksNâ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=infertham 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.