jvinhit//lab

Search posts

Type to search across journal entries.

navigate open esc close

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ỏ:

  1. Tiến con trỏ`${infer C}${infer Tail}` bóc một ký tự ở đầu; C tham lam (một ký tự), Tail là phần còn lại.
  2. LookaheadTail extends Uncapitalize<Tail> hỏi “ký tự kế có bắt đầu từ mới không?” mà không tiêu thụ nó.
  3. Phát — luôn xuất Uncapitalize<C>; có điều kiện nối - trước khi đệ quy.
  4. Base case — chuỗi rỗng (không còn C để bóc) trả S nguyê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

  • Uncapitalize chỉ đụng ký tự đầu — đúng ý lookahead trên Tail, 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ách XML thành x-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ỗngKebabCase<''>''; 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:

  1. Dequeue — mẫu tuple [infer Head, ...infer Tail] bóc key đầu ở phía trước (như shift() trên mảng).
  2. Đệ quy — tính cấu trúc trong từ các key còn lại.
  3. Bọc{ [K in Head]: inner } thêm một tầng lồng quanh kết quả đệ quy.
  4. 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>; // boolean

Cạm bẫy

  • PropertyKey, không chỉ string — key có thể là number hoặc symbol; ràng buộc Head extends PropertyKey giữ mapped type hợp lệ.
  • Tail extends PropertyKey[] ? Tail : [] — sau ...infer Tail, TypeScript suy luận Tail lỏng; guard ngăn rest type lỗi làm hỏng đệ quy.
  • Thứ tự key quan trọng['a', 'b']['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 literaltoken (%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ế

StatePatternĐ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
ActControl extends keyof ControlsMapBiết → phát ControlsMap[Control] và đệ quy Tail; không biết → bỏ qua và quét lại từ Rest
Doneno % leftTrả []

Đệ quy vòng lặp; Rest / Tail 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' found

Cạm bẫy

  • Vì sao Rest ở nhánh unknown, không phải Tail? — Cả hai đều dừng, nhưng Rest và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 ở Sep sớm nhất (xem Phần 7 về infer tham 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ỗngSplit<'a..b', '.'>['a', '', 'b']; ngữ pháp cho phép Head độ 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ả RestTail đề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.