TS Type Challenges · Part 10 — Parsers & State Machines
Part 10: parse strings at the type level. Convert camelCase to kebab-case, fold a tuple into a nested object, and build a real printf format parser that walks a string as a state machine — each with a toggle-to-reveal answer.
Đây là Phần 10 của series 12 bài về TypeScript type challenges. Giờ ta cho các công cụ chuỗi từ Phần 7 làm việc thật: parse. Một parser duyệt đầu vào từng token, mang theo trạng thái và phát ra kết quả — và đó đúng là điều conditional type đệ quy làm.
Ghi nguồn: các đáp án theo type-challenges (giấy phép MIT); số khớp ID chính thức.
612 · KebabCase
Mục tiêu
Đổi camelCase/PascalCase sang kebab-case — chèn dấu gạch ở mọi ranh giới từ và hạ chữ mọi ký tự.
type A = KebabCase<'FooBarBaz'>; // 'foo-bar-baz'
type B = KebabCase<'doNothing'>; // 'do-nothing'
Vì sao không hiển nhiên
Ranh giới từ trong camelCase không phải ký tự bạn khớp trực tiếp — trong input không có -. Ranh giới là ngầm: nằm giữa chữ thường và chữ hoa bắt đầu từ kế. Bạn không thể viết `${infer Before}-${infer After}` rồi mong TypeScript tự tìm ranh giới. Bạn phải nhìn ký tự kế (lookahead) mỗi bước và quyết định có phát dấu gạch không.
Thử ngây thơ — và vì sao thất bại
// ❌ Lowercase only — no dashes at word boundaries
type LowerOnly<S extends string> = Uncapitalize<S>;
// LowerOnly<'FooBar'> → 'fooBar' (wanted 'foo-bar')
// ❌ Insert '-' before every uppercase letter — breaks consecutive capitals
type DashBeforeUpper<S extends string> = S extends `${infer H}${infer U}${infer T}`
? U extends Uppercase<U>
? `${H}-${Uncapitalize<U>}${DashBeforeUpper<T>}`
: `${H}${U}${DashBeforeUpper<T>}`
: S;
// DashBeforeUpper<'XMLHttpRequest'> → 'xMLHttpRequest' or worse — no rule for 'XML'
// ❌ Map over a char union — strings are not tuples; you can't index them
type CharByChar<S extends string> = S[0]; // string types have no numeric index
Cách sửa là parser lookahead một ký tự: bóc C (ký tự hiện tại) và Tail (phần còn lại), xem Tail có bắt đầu bằng chữ hoa không, rồi phát C (đã hạ chữ) cộng - tùy chọn.
Hiện đáp án
type KebabCase<S extends string> = S extends `${infer C}${infer Tail}`
? Tail extends Uncapitalize<Tail>
? `${Uncapitalize<C>}${KebabCase<Tail>}`
: `${Uncapitalize<C>}-${KebabCase<Tail>}`
: S;Cơ chế
Bốn ý ghép thành một máy trạng thái nhỏ:
- Tiến con trỏ —
`${infer C}${infer Tail}`bóc một ký tự ở đầu;Ctham lam (một ký tự),Taillà phần còn lại. - Lookahead —
Tail extends Uncapitalize<Tail>hỏi “ký tự kế có bắt đầu từ mới không?” mà không tiêu thụ nó. - Phát — luôn xuất
Uncapitalize<C>; có điều kiện nối-trước khi đệ quy. - Base case — chuỗi rỗng (không còn
Cđể bóc) trảSnguyên (tức'').
Trạng thái ngầm là “tôi có đang ở ranh giới từ không?” — trả lời bằng cách nhìn chữ hoa/thường của ký tự đầu Tail.
Từng dòng
type KebabCase<S extends string> = S extends `${infer C}${infer Tail}`
// If S has at least one character, bind C = first char, Tail = rest.
// If S is '', this branch fails → fall through to : S (base case).
? Tail extends Uncapitalize<Tail>
// LOOKAHEAD: Uncapitalize<Tail> lowercases only the FIRST char of Tail.
// If Tail already starts lowercase, Uncapitalize<Tail> === Tail → true branch.
// If Tail starts uppercase, they differ → false branch → insert '-'.
? `${Uncapitalize<C>}${KebabCase<Tail>}`
// No boundary after C: emit lowercased C, recurse on Tail (cursor moves 1).
: `${Uncapitalize<C>}-${KebabCase<Tail>}`
// Boundary after C: emit lowercased C + '-', recurse on Tail.
: S;
// Base case: S is empty string → done.Đánh giá type từng bước
Theo dõi KebabCase<'doNothing'> — chỗ khó nhất là chữ N trong Nothing:
// step 0: S = 'doNothing', cursor at start, output accumulator = ''
// step 1: peel C='d', Tail='oNothing'
// Tail extends Uncapitalize<Tail>?
// Uncapitalize<'oNothing'> = 'oNothing' → YES (next char is lowercase)
// emit 'd', no dash → partial output 'd', recurse on 'oNothing'
// step 2: peel C='o', Tail='Nothing'
// Uncapitalize<'Nothing'> = 'nothing' ≠ 'Nothing' → NO (next char is uppercase 'N')
// emit 'o' + '-' → partial output 'do-', recurse on 'Nothing'
// step 3: peel C='N', Tail='othing'
// Uncapitalize<'othing'> = 'othing' → YES
// emit 'n', no dash → partial output 'do-n', recurse on 'othing'
// step 4: peel C='o', Tail='thing'
// YES → emit 'o' → 'do-no', recurse on 'thing'
// step 5: peel C='t', Tail='hing'
// YES → emit 't' → 'do-not', recurse on 'hing'
// step 6: peel C='h', Tail='ing'
// YES → emit 'h' → 'do-noth', recurse on 'ing'
// step 7: peel C='i', Tail='ng'
// YES → emit 'i' → 'do-nothi', recurse on 'ng'
// step 8: peel C='n', Tail='g'
// YES → emit 'n' → 'do-nothin', recurse on 'g'
// step 9: peel C='g', Tail=''
// Tail='' extends Uncapitalize<''>? → '' extends '' → YES
// emit 'g' → 'do-nothing', recurse on ''
// step 10: S='' → base case → ''
type Result = KebabCase<'doNothing'>; // 'do-nothing'Trace ngắn cho KebabCase<'FooBar'>:
// step 1: C='F', Tail='ooBar' → Tail starts lowercase 'o' → emit 'f' (no dash)
// step 2: C='o', Tail='oBar' → Tail starts lowercase 'o' → emit 'o'
// step 3: C='o', Tail='Bar' → Tail starts uppercase 'B' → emit 'o-'
// step 4: C='B', Tail='ar' → Tail starts lowercase → emit 'b'
// step 5: C='a', Tail='r' → emit 'a'
// step 6: C='r', Tail='' → emit 'r'
// step 7: '' → done
type Result = KebabCase<'FooBar'>; // 'foo-bar'Cạm bẫy
Uncapitalizechỉ đụng ký tự đầu — đúng ý lookahead trênTail, nhưng đừng dùng nó để hạ chữ cả phần còn lại.- Chữ hoa liên tiếp (
XMLHttpRequest,IO) — thuật toán này chèn gạch trước mọi chữ hoa sau chữ thường; không táchXMLthànhx-m-l. Cần ngữ pháp phức tạp hơn. - Độ sâu đệ quy — một lần gọi đệ quy mỗi ký tự; string literal type rất dài có thể chạm giới hạn độ sâu compiler.
- Chuỗi rỗng —
KebabCase<''>→''; nhánh`${infer C}${infer Tail}`không khớp, rơi vào base case gọn.
Tóm lại: bóc một ký tự, nhìn chữ hoa/thường ký tự kế để quyết định -, hạ chữ ký tự hiện tại, đệ quy — parser lookahead một ký tự.
3188 · Tuple to Nested Object
Mục tiêu
Gập một tuple key thành object lồng nhau mà value sâu nhất là U.
type A = TupleToNestedObject<['a', 'b'], number>;
// Expected: { a: { b: number } }
type B = TupleToNestedObject<[], boolean>;
// Expected: boolean
Vì sao không hiển nhiên
Bạn dựng cấu trúc dữ liệu lớn dần trong khi đầu vào nhỏ dần — ngược với hầu hết parser phát kết quả phẳng. Bạn không thể “gán” thuộc tính lồng nhau từng bước ở tầng type; mỗi bước đệ quy phải bọc kết quả tích luỹ trong một lớp object mới. Tuple rỗng [] không phải lỗi — đó là tín hiệu đã tới lá và nên trả U.
Thử ngây thơ — và vì sao thất bại
// ❌ Hard-coded depth — only works for exactly two keys
type TwoLevel<K1 extends string, K2 extends string, U> = { [k in K1]: { [k2 in K2]: U } };
// ❌ Mapped type over the tuple gives a flat object, not nested
type FlatWrong<T extends readonly string[], U> = { [K in T[number]]: U };
// FlatWrong<['a', 'b'], number> → { a: number; b: number } (wanted nesting)
// ❌ Reverse the tuple and hope — still no automatic nesting without recursion
type ReverseAndPray<T extends readonly string[], U> = T extends [...infer R, infer Last]
? { [K in Last & string]: U } // only one level; where do earlier keys go?
: U;
Cách sửa là fold (reduce) trên tuple: lấy Head, đệ quy dựng cấu trúc trong từ Tail, rồi bọc { [Head]: inner }.
Hiện đáp án
type TupleToNestedObject<T extends readonly PropertyKey[], U> = T extends [
infer Head extends PropertyKey,
...infer Tail,
]
? { [K in Head]: TupleToNestedObject<Tail extends PropertyKey[] ? Tail : [], U> }
: U;Cơ chế
Đây là fold lồng phải với accumulator bắt đầu là giá trị lá U và được bọc ra ngoài mỗi bước:
- Dequeue — mẫu tuple
[infer Head, ...infer Tail]bóc key đầu ở phía trước (nhưshift()trên mảng). - Đệ quy — tính cấu trúc trong từ các key còn lại.
- Bọc —
{ [K in Head]: inner }thêm một tầng lồng quanh kết quả đệ quy. - Base case — tuple rỗng
[]không khớp mẫu[Head, ...Tail]→ trảU.
Trạng thái ở đây là đường key còn lại (Tail) và độ lồng tích luỹ dựng khi đệ quy trở về.
Từng dòng
type TupleToNestedObject<T extends readonly PropertyKey[], U> = T extends [
infer Head extends PropertyKey,
// Head must be a valid object key (string | number | symbol).
...infer Tail,
// Tail is "whatever is left" — might not be a tuple yet; we guard below.
]
? { [K in Head]: TupleToNestedObject<Tail extends PropertyKey[] ? Tail : [], U> }
// Mapped type with exactly ONE key (Head). Value is the recursive call on Tail.
// Tail extends PropertyKey[] ? Tail : [] — if Tail isn't a key tuple, treat as [].
: U;
// T is [] (or not a non-empty tuple) → we've consumed all keys → return leaf U.Đánh giá type từng bước
Theo dõi TupleToNestedObject<['a', 'b', 'c'], number>:
// step 0: T = ['a', 'b', 'c'], U = number
// keys remaining: ['a', 'b', 'c'], accumulator not built yet
// step 1: match [Head, ...Tail] → Head='a', Tail=['b', 'c']
// must compute inner = TupleToNestedObject<['b', 'c'], number>
// state: wrap 'a' around whatever inner becomes
// step 2: T = ['b', 'c']
// Head='b', Tail=['c']
// must compute inner = TupleToNestedObject<['c'], number>
// step 3: T = ['c']
// Head='c', Tail=[]
// must compute inner = TupleToNestedObject<[], number>
// step 4: T = [] → base case → inner = number
// (leaf reached; accumulator = number)
// step 5: unwind step 3 → { c: number }
// step 6: unwind step 2 → { b: { c: number } }
// step 7: unwind step 1 → { a: { b: { c: number } } }
type Result = TupleToNestedObject<['a', 'b', 'c'], number>;
// { a: { b: { c: number } } }Base case tuple rỗng:
// step 0: T = [], U = boolean
// step 1: [] extends [infer Head, ...infer Tail]? → NO
// step 2: base case → boolean
type Result = TupleToNestedObject<[], boolean>; // booleanCạm bẫy
PropertyKey, không chỉstring— key có thể lànumberhoặcsymbol; ràng buộcHead extends PropertyKeygiữ mapped type hợp lệ.Tail extends PropertyKey[] ? Tail : []— sau...infer Tail, TypeScript suy luậnTaillỏng; guard ngăn rest type lỗi làm hỏng đệ quy.- Thứ tự key quan trọng —
['a', 'b']và['b', 'a']cho hình dạng khác; fold này không giao hoán. - Độ sâu = độ dài tuple — tuple 20 phần tử nghĩa là 20 tầng lồng; cùng giới hạn độ sâu đệ quy như parser chuỗi.
Tóm lại: lấy key đầu, đệ quy dựng object trong, bọc một lớp — fold làm lồng sâu dần khi tuple co lại.
147 · Parser định dạng printf
Mục tiêu
Máy trạng thái thực sự. Cho một chuỗi định dạng kiểu printf, sinh ra tuple các type tham số mà mỗi placeholder cần.
type A = ParsePrintFormat<'The result is %d.'>; // [number]
type B = ParsePrintFormat<'%s is %d years old.'>; // [string, number]
type C = ParsePrintFormat<'no placeholders here'>; // []
Vì sao không hiển nhiên
Chuỗi printf trộn text literal và token (%d, %s, …) trong một string phẳng. Bạn phải quét đến %, đọc ký tự kế làm specifier điều khiển, phát một type, rồi tiếp tục — quét → tiêu thụ → xử lý → lặp kinh điển. Specifier không biết phải bỏ qua mà không làm kẹt quét. Kết quả là tuple dựng trái sang phải — mỗi placeholder tìm được ghép type của nó vào kết quả đệ quy.
Thử ngây thơ — và vì sao thất bại
// ❌ Match only '%d' literally — misses '%s', '%f', etc.
type OnlyD<S extends string> = S extends `${string}%d${infer Rest}`
? [number, ...OnlyD<Rest>]
: [];
// ❌ Require fixed prefix — fails when format doesn't start with '%'
type StartsWithPercent<S extends string> = S extends `%${infer C}${infer Rest}`
? [C, ...StartsWithPercent<Rest>] // C is string, not mapped to number/string
: [];
// ❌ Replace all '%x' with a union — no tuple, no order, no per-specifier typing
type ReplaceAll<S extends string> = S extends `${infer H}%${infer T}`
? `${H}${unknown}${ReplaceAll<T>}`
: S;
Cách sửa là máy ba trạng thái mã hoá bằng conditional lồng nhau: (1) quét %, (2) tiêu thụ ký tự điều khiển, (3) phân loại và phát.
Hiện đáp án
type ControlsMap = {
c: string;
s: string;
d: number;
o: number;
x: number;
X: number;
f: number;
e: number;
g: number;
a: number;
A: number;
p: string;
};
type ParsePrintFormat<S extends string> =
S extends `${string}%${infer Rest}`
? Rest extends `${infer Control}${infer Tail}`
? Control extends keyof ControlsMap
? [ControlsMap[Control], ...ParsePrintFormat<Tail>]
: ParsePrintFormat<Rest>
: []
: [];Cơ chế
| State | Pattern | Điều gì xảy ra |
|---|---|---|
| Scan | `${string}%${infer Rest}` | Bỏ qua text literal đến % kế; Rest là mọi thứ sau % |
| Consume | `${infer Control}${infer Tail}` | Tách Rest thành một ký tự điều khiển + phần dư Tail |
| Act | Control extends keyof ControlsMap | Biết → phát ControlsMap[Control] và đệ quy Tail; không biết → bỏ qua và quét lại từ Rest |
| Done | no % left | Trả [] |
Đệ quy là vòng lặp; Rest / Tail là con trỏ.
Từng dòng
type ParsePrintFormat<S extends string> =
S extends `${string}%${infer Rest}`
// SCAN state: `${string}%` greedily skips any literal prefix up to the first '%'.
// Rest = everything AFTER that '%' (the '%' itself is consumed).
? Rest extends `${infer Control}${infer Tail}`
// CONSUME state: peel one char as Control, rest as Tail.
// If Rest is '' (format ends with bare '%'), this fails → return [].
? Control extends keyof ControlsMap
// ACT state — known specifier?
? [ControlsMap[Control], ...ParsePrintFormat<Tail>]
// Emit mapped type, prepend to tuple, advance cursor to Tail.
: ParsePrintFormat<Rest>
// Unknown Control (e.g. '%q'): do NOT recurse on Tail alone.
// Re-enter SCAN from Rest so the next '%' is found uniformly.
: []
// Rest is empty after '%' → malformed trailing '%' → no args
: [];
// No '%' in S → scanning done → empty arg listĐánh giá type từng bước
Theo dõi ParsePrintFormat<'%s is %d years old.'>:
// step 0: S = '%s is %d years old.', output tuple = (building), cursor at start
// ── first placeholder ──
// step 1 (SCAN): '%s is %d years old.' matches `${string}%${infer Rest}`
// literal prefix = '' (empty), Rest = 's is %d years old.'
// step 2 (CONSUME): Rest → Control='s', Tail=' is %d years old.'
// step 3 (ACT): 's' extends keyof ControlsMap? → YES → ControlsMap['s'] = string
// emit string, recurse on Tail=' is %d years old.'
// partial tuple: [string]
// ── second placeholder ──
// step 4 (SCAN): ' is %d years old.' → skip ' is ', Rest = 'd years old.'
// step 5 (CONSUME): Control='d', Tail=' years old.'
// step 6 (ACT): 'd' known → ControlsMap['d'] = number
// emit number, recurse on Tail=' years old.'
// partial tuple: [string, number]
// ── no more placeholders ──
// step 7 (SCAN): ' years old.' has no '%' → branch fails → []
// step 8: unwind → [string, number] ++ [] = [string, number]
type Result = ParsePrintFormat<'%s is %d years old.'>; // [string, number]Trace phục hồi specifier không biết: ParsePrintFormat<'%q%d'>:
// step 1 (SCAN): Rest = 'q%d'
// step 2 (CONSUME): Control='q', Tail='%d'
// step 3 (ACT): 'q' NOT in ControlsMap → ParsePrintFormat<Rest>
// i.e. ParsePrintFormat<'q%d'> (NOT ParsePrintFormat<'%d'> via Tail!)
// step 4 (SCAN): 'q%d' → skip 'q', Rest = 'd' (wait — no second '%' yet!)
// Actually: 'q%d' matches `${string}%${infer Rest}` with prefix 'q', Rest='d'
// step 5 (CONSUME): Control='d', Tail=''
// step 6 (ACT): 'd' known → [number]
// step 7: SCAN on '' → []
type Result = ParsePrintFormat<'%q%d'>; // [number] — '%q' skipped, '%d' foundCạm bẫy
- Vì sao
Restở nhánh unknown, không phảiTail? — Cả hai đều dừng, nhưngRestvào lại trạng thái quét (“tìm%kế”), giữ một đường code.Tailđã bỏ ký tự lạ và vẫn chạy ở đây, nhưng lệch tinh thần “luôn quét trước”. %đơn ở cuối —'%'→Rest=''→ consume thất bại →[], không phải error type.%trong text literal — mẫu quét chỉ kích hoạt trên%;%literal trong output ổn, nhưng%%(percent thoát) không được ngữ pháp này xử lý.- Thứ tự tuple = thứ tự quét trái sang phải — ghép đầu
[ControlsMap[Control], ...]giữ thứ tự placeholder xuất hiện trong chuỗi. - Độ sâu đệ quy — hai lần gọi đệ quy mỗi placeholder (quét + tiếp tục); chuỗi format dài cộng dồn.
Tóm lại: quét %, tiêu thụ một ký tự điều khiển, ánh xạ qua ControlsMap hoặc bỏ qua — parser ba trạng thái mà vòng lặp là đệ quy và con trỏ là Rest/Tail.
Bài tập
1. Viết Split<S, Sep> trả tuple các đoạn.
Vì sao là nền tảng
Split là tokenizer mà mọi parser khác trong bài này dựa vào — ParsePrintFormat quét %, KebabCase đi từng ký tự, và bài nâng cao ParseQueryString tách theo & rồi =. Cùng công thức: tìm separator đầu tiên, phát phần đầu, đệ quy phần đuôi.
Lời giải
type Split<
S extends string,
Sep extends string,
> = S extends `${infer Head}${Sep}${infer Tail}`
? [Head, ...Split<Tail, Sep>]
: [S];Đánh giá type từng bước
Theo dõi Split<'a.b.c', '.'>:
// step 0: S = 'a.b.c', Sep = '.', segments = (building)
// step 1: match Head='a', Sep='.', Tail='b.c'
// emit 'a', recurse on 'b.c' → partial ['a', ...]
// step 2: Head='b', Tail='c'
// emit 'b', recurse on 'c' → partial ['a', 'b', ...]
// step 3: 'c' has no '.' → base case → ['c']
// unwind: ['a', 'b', 'c']
type Result = Split<'a.b.c', '.'>; // ['a', 'b', 'c']Cạm bẫy
- Chỉ separator đầu mỗi lần gọi —
`${infer Head}${Sep}${infer Tail}`tách ởSepsớm nhất (xem Phần 7 vềinfertham lam/không tham lam). - Hết separator — cả chuỗi thành đoạn cuối một phần tử
[S]. - Đoạn rỗng —
Split<'a..b', '.'>→['a', '', 'b']; ngữ pháp cho phépHeadđộ dài 0.
Tóm lại: tách ở separator đầu, phát Head, đệ quy Tail; khi hết separator, trả [S].
2. Trong parser printf, điều gì hỏng nếu nhánh control-không-biết đệ quy trên Tail thay vì Rest?
Lời giải
Không cái nào lặp vô tận — cả Rest và Tail đều ngắn hơn chuỗi gốc, nên đệ quy luôn dừng. Khác biệt là trạng thái parser nào bạn vào lại:
// Unknown '%q' in '%q%d':
// Rest = 'q%d' Tail = '%d'
// Recurse on Rest → ParsePrintFormat<'q%d'>
// SCAN skips 'q', finds '%', parses '%d' → [number] ✓
// Recurse on Tail → ParsePrintFormat<'%d'>
// SCAN finds '%' immediately, parses '%d' → [number] ✓
// (same result for THIS input)Với '%q' đơn (không có % sau), đệ quy trên Tail khi Tail='' trả [] ngay — bạn không bao giờ “thử lại” q lạ từ trạng thái quét, nhưng cũng không còn gì để tìm. Dùng Rest giữ mọi nhánh tiếp tục đồng nhất: luôn quét lại % kế thay vì đôi khi nhảy thẳng sang consume. Tính đồng nhất quan trọng khi mở rộng ngữ pháp (width, precision, %%) — một điểm vào cho “tiếp tục parse”.
Tóm lại: Tail thường vẫn chạy với input đơn giản, nhưng Rest giữ hình dạng máy trạng thái quét-trước.
Nâng cao:dựng ParseQueryString<S> biến 'a=1&b=2' thành { a: '1'; b: '2' }, kết hợp Split với khớp mẫu key/value.
Điểm chính
- Một parser = đệ quy làm vòng lặp + phần dư đã bắt làm con trỏ.
- Lookahead (nhìn chữ hoa/thường ký tự kế) quyết định ranh giới từ, như
KebabCase. - Một fold trên tuple dựng được cấu trúc lồng, không chỉ phẳng.
- Các trạng thái parser kinh điển — quét → tiêu thụ → xử lý → lặp — ánh xạ gọn vào conditional type đệ quy.
Tiếp theo
Phần 11 — Bài cực khó: vài bài khó nhất repo, đi từ từ. Ta sẽ giải những bài gộp mọi thứ — đệ quy sâu, parse, và tích luỹ trạng thái — và cho thấy ngay cả type “cực khó” cũng chỉ là các mẫu bạn đã biết, xếp chồng.