jvinhit//lab

Search posts

Type to search across journal entries.

navigate open esc close

TS Type Challenges · Part 6 — Union Manipulation

Part 6: unions are not tuples. Turn a string into a union, recap distribution, extract the last member of a union, and build the famous UnionToTuple with the contravariance gadget — each with a toggle-to-reveal answer and explanation.

Đây là Phần 6 của series 12 bài về TypeScript type challenges. Tuple có thứ tự và vị trí; union thì không. Bạn không thể viết U[0] trên union. Nên thao tác union cần công cụ khác: distribution để thăm từng phần tử, và một đồ nghề contravariance khôn khéo để rút từng phần tử ra.

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


531 · String to Union

Tách một string type thành union các ký tự.

type R = StringToUnion<'hello'>; // 'h' | 'e' | 'l' | 'l' | 'o'  (= 'h'|'e'|'l'|'o')

Vì sao không hiển nhiên

Một string literal như 'hello' là một type duy nhất, không phải tuple gồm năm type ký tự. Bạn không thể index vào nó như index tuple — không có S[0] ở tầng type. Mục tiêu là phân rã một chuỗi thành tập các literal ký tự nối bằng |. Việc phân rã đó phải qua khớp mẫu, không phải truy cập theo vị trí.

Cách làm ngây thơ

Bạn có thể thử dựng tuple trước rồi chuyển đổi — hoặc đệ quy bằng spread như các bài tuple:

// ❌ Wrong direction — builds a tuple, not a union
type Naive<S extends string> = S extends `${infer H}${infer T}`
  ? [H, ...Naive<T>]
  : [];

type Bad = Naive<'hi'>; // ['h', 'i'] — ordered list, not a union

Kết quả là ['h', 'i'], giữ thứ tự và trùng lặp. Bài yêu cầu 'h' | 'i' — một union, nơi trùng lặp gộp lại và không có thứ tự. Cách sửa không nằm ở khung đệ quy (đầu + đuôi) mà ở toán tử kết hợp mỗi bước: | thay cho [..., ...].

Hiện đáp án
type StringToUnion<T extends string> = T extends `${infer Head}${infer Tail}`
  ? Head | StringToUnion<Tail>
  : never;

Cơ chế: suy luận template + đệ quy union

  1. Tách Head / Tail — conditional khớp T với mẫu template literal bóc ký tự đầu thành Head và giữ phần còn lại là Tail (xem khối đáp án phía trên).
  2. Head | StringToUnion<Tail> — hợp đầu với kết quả đệ quy từ đuôi.
  3. never base case — khi T'', mẫu template không khớp nên nhánh conditional rơi về never. Trong union, X | never rút gọn thành Xneverunion rỗng, nó biến mất.

Đây là đệ quy đầu/đuôi giống các phần trước; chỉ có accumulator đổi từ spread tuple sang pipe union.

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

Theo dõi StringToUnion<'hi'> mở ra:

// step 1: T = 'hi' — matches `${infer Head}${infer Tail}`
//   Head = 'h', Tail = 'i'
type Step1 = 'h' | StringToUnion<'i'>;

// step 2: T = 'i'
//   Head = 'i', Tail = ''
type Step2 = 'h' | ('i' | StringToUnion<''>);

// step 3: T = '' — does NOT match `${infer Head}${infer Tail}`
//   falls to never
type Step3 = 'h' | ('i' | never);

// step 4: 'i' | never  →  'i'
type Step4 = 'h' | 'i';

// final
type R = 'h' | 'i';

Với 'hello', hai ký tự 'l' đều đóng góp 'l' vào union, nhưng 'l' | 'l' gộp thành 'l'. Chuỗi gốc giữ trùng lặp; kết quả union thì không.

Cạm bẫy

  • Trùng lặp gộp lại'll' ở input vẫn chỉ cho 'l' một lần ở output.
  • never không phải lỗi ở đây — nó là giá trị “không thêm gì” có chủ đích cho union.
  • Suy luận tham lam${infer Head}${infer Tail} luôn lấy đúng một ký tự làm Head; Head nhiều ký tự cần mẫu khác.

Tóm lại: bóc một ký tự, hợp với đuôi đệ quy, dừng ở chuỗi rỗng bằng never.


Ôn lại — distribution thăm từng phần tử

Bạn đã biết từ Phần 3, nhưng đây là cốt lõi của việc xử lý union nên hãy neo lại với một đánh giá đầy đủ.

type Box<U> = U extends any ? [U] : never;
type R = Box<'a' | 'b'>; // ['a'] | ['b']  (NOT ['a' | 'b'])

Vì sao không hiển nhiên

Khi viết U extends any ? [U] : never, bạn có thể kỳ vọng compiler bọc cả union trong một tuple: ['a' | 'b']. Thay vào đó bạn nhận ['a'] | ['b'] — hai type tuple riêng nối bằng |. Conditional phân phối qua các phần tử union khi type được kiểm tra (U) là tham số type trần.

Cách làm ngây thơ

Không có trigger extends any, conditional trên union không luôn phân phối như bạn muốn:

// ❌ Non-distributive — U is wrapped in a tuple
type NoDistribute<U> = [U] extends [any] ? [U] : never;
type Stuck = NoDistribute<'a' | 'b'>; // ['a' | 'b'] — whole union, one box

Dấu ngoặc [U] chặn phân phối: compiler thấy một type union, không phải hai lần kiểm tra riêng. Thành ngữ U extends any ? … : … giữ U trần bên trái, nên mỗi phần tử được thăm độc lập.

Cơ chế

U extends any ? [U] : never nghĩa là: với mỗi phần tử M của U, nếu M extends any (luôn đúng), sinh [M]; rồi hợp tất cả kết quả. Ta dùng any vì nó khớp mọi type, nên nhánh true luôn chạy — mục đích là phân phối, không phải lọc.

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

// Given: Box<'a' | 'b'>

// step 1: distribute — compiler rewrites as two conditionals
//   ('a' extends any ? ['a'] : never) | ('b' extends any ? ['b'] : never)

// step 2: evaluate each branch — both extend any, so both take true branch
//   ['a'] | ['b']

type R = ['a'] | ['b'];

Cạm bẫy

  • boolean thực ra là unionboolean = true | false, nên Box<boolean> phân phối thành [true] | [false].
  • Thứ tự không được bảo đảm'a' | 'b''b' | 'a' là cùng một union; thứ tự phân phối theo compiler, không theo thứ tự bạn viết.
  • Bọc ngoặc chặn phân phối[U] extends [any]U extends [any] khác nhau; mẹo dấu ngoặc ở Phần 3 là cách tắt phân phối khi cần.

Tóm lại: U extends any ? … : … là thành ngữ “chạy một lần cho mỗi phần tử union”. Tiếp theo ta dùng nó để biến phần tử union thành hàm.


Phần tử cuối của một union

Union không có “phần tử cuối”, nhưng ta có thể buộc compiler chọn một bằng contravariance. Đây là động cơ phía sau UnionToTuple.

type R = LastInUnion<1 | 2>; // 2

Vì sao không hiển nhiên

Bạn không thể viết U[LastIndex] hay “cho tôi phần tử cuối của union này” — union là tập không thứ tự. Bạn cũng không thể infer một phần tử duy nhất từ union trần bằng một conditional. Mẹo là diễn đạt lại bài toán: biến mỗi phần tử thành hàm, gộp union hàm thành intersection, rồi đọc lại type tham số.

Cách làm ngây thơ

// ❌ Distribution gives you ALL members, not one
type PickAny<U> = U extends infer L ? L : never;
type Messy = PickAny<1 | 2>; // 1 | 2 — still the whole union

// ❌ No "last" keyword exists
type Wishful<U> = U extends any ? U[number] : never; // U[number] on a union is just U again

Phân phối với infer cho union của mọi thứ nó thăm — ngược với “chọn đúng một”. Ta cần cơ chế gộp union thành dạng mà chỉ một phần tử sống sót qua suy luận.

Hiện đáp án
type UnionToIntersection<U> = (
  U extends any ? (arg: U) => void : never
) extends (arg: infer I) => void
  ? I
  : never;

type LastInUnion<U> =
  UnionToIntersection<U extends any ? (x: U) => void : never> extends (
    x: infer L,
  ) => void
    ? L
    : never;

Cơ chế: phân phối → giao → suy luận

UnionToIntersection<U> (the contravariance gadget from Part 1):

  1. U extends any ? (arg: U) => void : never — phân phối: biến mỗi phần tử M thành hàm (arg: M) => void.
  2. Kết quả là union các hàm: ((arg: 1) => void) | ((arg: 2) => void).
  3. Union đó được kiểm tra với (arg: infer I) => void. Vì tham số hàm contravariant, compiler giao các type tham số: I = 1 & 2 sẽ là never với type không liên quan — nhưng với phân giải overload trên intersection chữ ký gọi, chữ ký cuối thắng khi bạn infer một tham số.

LastInUnion<U> applies that gadget twice, on purpose:

  1. Dựng union hàm (x: U) => void qua phân phối.
  2. Đưa qua UnionToIntersection → intersection các chữ ký gọi (như hàm overload).
  3. extends (x: infer L) => void — suy luận type tham số từ intersection đó; phân giải overload chọn tham số của phần tử cuối làm L.

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

Với LastInUnion<1 | 2>:

// step 1: distribute U extends any ? (x: U) => void : never
//   →  (x: 1) => void  |  (x: 2) => void
type Fns = (x: 1) => void | (x: 2) => void;

// step 2: UnionToIntersection collapses the function union
//   →  ((x: 1) => void) & ((x: 2) => void)
//   (one callable type with two overload signatures)
type Intersected = ((x: 1) => void) & ((x: 2) => void);

// step 3: Intersected extends (x: infer L) => void ?
//   overload resolution: last signature wins → L = 2
type L = 2;

type R = 2;

“Cuối” là chi tiết cài đặt của việc gộp overload TypeScript, nhưng ổn định qua các phiên bản với mẫu này — và sự ổn định đó là đủ cho thuật toán bóc từng lớp.

Cạm bẫy

  • Không được spec bảo đảm — spec TypeScript không hứa “overload cuối thắng”; đó là hành vi compiler quan sát được.
  • Thứ tự phần tử trong source không quan trọng1 | 22 | 1 là cùng type; “cuối” nghĩa là cuối theo thứ tự nội bộ compiler.
  • boolean phân phối thành hai hàmLastInUnion<boolean> cho false (cuối của true | false theo thứ tự ổn định).

Tóm lại: phân phối thành hàm, giao chúng, suy luận tham số cuối — đó là một lần bóc tất định.


730 · Union to Tuple

Chuyển một union thành tuple các phần tử. (Thứ tự không được spec bảo đảm, nhưng test chấp nhận thứ tự ổn định của compiler.)

type R = UnionToTuple<1 | 2 | 3>; // [1, 2, 3]

Vì sao không hiển nhiên

Tuple là danh sách có thứ tự; union là túi không thứ tự. Không có UnionToTuple sẵn trong thư viện chuẩn. Bạn phải tạo ra thứ tự bằng cách lặp rút một phần tử, ghi lại, rồi đệ quy trên phần còn lại. Điều đó cần hai kỹ năng phụ từ các phần trước: bóc phần tử cuối (LastInUnion) và phát hiện union rỗng ([U] extends [never]).

Cách làm ngây thơ

// ❌ Distribution builds a union of singleton tuples, not one tuple
type Naive<U> = U extends any ? [U] : never;
type Wrong = Naive<1 | 2 | 3>; // [1] | [2] | [3] — still a union!

// ❌ No way to "append" across a union
type AlsoWrong<U, Acc extends unknown[] = []> = U extends any
  ? [...Acc, U] // Acc is shared — does not fold into one tuple
  : never;

Ánh xạ mỗi phần tử thành [U] cho union các tuple một phần tử, không phải một tuple nhiều phần tử. Cách sửa là loại trừ đệ quy: bóc một phần tử đã biết, tuple phần còn lại, nối phần tử vừa bóc vào cuối.

Hiện đáp án
type UnionToTuple<U, Last = LastInUnion<U>> = [U] extends [never]
  ? []
  : [...UnionToTuple<Exclude<U, Last>>, Last];

Cơ chế: bóc, loại, đệ quy, nối

  1. Last = LastInUnion<U> — chọn một phần tử tất định để bỏ ở vòng này (“cuối” từ đồ nghề trên).
  2. [U] extends [never] — mẹo dấu ngoặc: kiểm tra union rỗng mà không phân phối. Khi Unever, trả [].
  3. Exclude<U, Last> — bỏ Last khỏi U, cho union nhỏ hơn chặt chẽ.
  4. [...UnionToTuple<Exclude<U, Last>>, Last] — đệ quy trên union nhỏ hơn, spread kết quả, nối Last vào cuối.

Mỗi lần đệ quy thu nhỏ union đúng một phần tử cho đến khi chỉ còn never. Điều này khâu bốn ý tưởng trước: phân phối, UnionToIntersection, lá chắn [T] extends [never], và Exclude.

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

Với UnionToTuple<1 | 2> (ví dụ nhỏ hơn):

// ── Round 1: U = 1 | 2 ──
// step 1: Last = LastInUnion<1 | 2>  →  2
// step 2: [1 | 2] extends [never]?  →  false (not empty)
// step 3: Exclude<1 | 2, 2>  →  1
// step 4: [...UnionToTuple<1>, 2]

// ── Round 2: U = 1 ──
// step 5: Last = LastInUnion<1>  →  1
// step 6: [1] extends [never]?  →  false
// step 7: Exclude<1, 1>  →  never
// step 8: [...UnionToTuple<never>, 1]

// ── Round 3: U = never ──
// step 9: [never] extends [never]?  →  true
// step 10: UnionToTuple<never>  →  []

// ── Unwind ──
// step 11: UnionToTuple<1>  →  [...[], 1]  →  [1]
// step 12: UnionToTuple<1 | 2>  →  [...[1], 2]  →  [1, 2]

type R = [1, 2];

Với 1 | 2 | 3, vòng bóc-co tương tự chạy ba lần, nối 3, rồi 2, rồi 1 khi mỗi “cuối” bị bóc ra.

Cạm bẫy

  • Thứ tự theo compiler, không theo sourceUnionToTuple<2 | 1> vẫn có thể cho [1, 2] nếu đó là thứ tự bóc ổn định.
  • [U] extends [never] vs U extends never — không có ngoặc, U extends never sẽ phân phối và không bao giờ khớp base case đúng trên union nhiều phần tử.
  • Đừng dựa thứ tự tuple trong production — ổn cho puzzle và metaprogramming; rủi ro cho API hướng runtime.

Tóm lại: bóc Last, Exclude nó, đệ quy phần còn lại, nối vào — đến khi never cho [].


Đếm số phần tử của union

Một phần thưởng rơi thẳng từ UnionToTuple: một khi có tuple, độ dài của nó là kích thước union.

type UnionLength<U> = UnionToTuple<U>['length'];
type R = UnionLength<'a' | 'b' | 'c'>; // 3

Vì sao cách này chạy

Đếm phần tử của union trần không có toán tử trực tiếp — không có U.size. Nhưng tuple thuộc tính length ở tầng type (một number literal). Bắc cầu union → tuple → đọc ['length'].

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

// step 1: UnionToTuple<'a' | 'b' | 'c'>  →  ['a', 'b', 'c']  (stable order)
// step 2: ['a', 'b', 'c']['length']  →  3

type R = 3;

Cạm bẫy

  • Kế thừa cảnh báo thứ tự của UnionToTuple — nếu thứ tự không ổn định, độ dài vẫn đúng nhưng nội dung tuple không dùng để hiển thị.
  • boolean đếm là 2UnionLength<boolean>2, không phải 1, vì boolean = true | false.

Đây là chiêu hay gặp: khi câu hỏi về union khó, chuyển sang tuple, giải ở đó, rồi chuyển lại nếu cần. Tuple đơn giản là dễ duyệt hơn.

Tóm lại: UnionToTuple<U>['length'] biến “bao nhiêu phần tử?” thành “tuple dài bao nhiêu?”.


Bài tập

1. Viết Count<U> trả số phần tử của union dưới dạng number literal.

Lời giải
type Count<U> = UnionToTuple<U>['length'];

Tái dùng cây cầu tuple — không cần đếm union trực tiếp. Đánh giá giống hệt UnionLength ở trên: tuple hóa, rồi đọc length.

2. Vì sao LastInUnion cho phần tử cuối chứ không phải đầu hay bất kỳ?

Lời giải

UnionToIntersection biến union hàm thành intersection các chữ ký gọi — như một hàm overload. Khi infer một tham số từ type hàm overload, TypeScript chọn chữ ký overload cuối. Lựa chọn cài đặt đó ổn định, và thuật toán chỉ cần sự ổn định ấy.

// Intersection of two call signatures behaves like:
type Overloaded = {
  (x: 1): void;
  (x: 2): void;
};
// infer L from (x: infer L) => void against Overloaded → L = 2 (last wins)

Nâng cao:spec không bảo đảm thứ tự union. Dựng một ví dụ nhỏ và suy nghĩ vì sao dựa vào thứ tự trong code production là rủi ro.

type A = UnionToTuple<1 | 2 | 3>;
type B = UnionToTuple<3 | 1 | 2>; // same members, different source order
// A and B are typically identical — order is compiler-defined, not author-defined

Điểm chính

  • Đệ quy có thể dựng union (kết hợp |) y như dựng tuple (kết hợp spread).
  • U extends any ? … : … là thành ngữ “làm điều này với từng phần tử union”.
  • never là union rỗng — biến mất trong X | never và báo hiệu “dừng tích lũy” trong builder đệ quy.
  • UnionToIntersection + infer trên kết quả rút phần tử cuối của union, tất định.
  • UnionToTuple = bóc Last, Exclude nó, đệ quy — chặn bởi [U] extends [never].
  • Khi union khó xử, bắc cầu sang tuple, giải, rồi quay lại.

Tiếp theo

Phần 7 — Template Literal Type: tính toán chuỗi ở tầng type. Ta dựng Trim, Replace, ReplaceAll, StartsWith/EndsWith, LengthOfString, dựa vào mẫu tách-rồi-đệ-quy vừa dùng để biến string thành union.