jvinhit//lab

Search posts

Type to search across journal entries.

navigate open esc close

TS Type Challenges · Part 3 — Conditional Types & Distribution

Part 3: the A extends B ? X : Y engine. Build If, IsNever, IsUnion and AnyOf, nail the distributive rule (and switch it off with [T]), and decode the Equal gadget behind every test — each with a toggle-to-reveal answer.

Đây là Phần 3 của series 12 bài về TypeScript type challenges. Ta gặp conditional type ở Phần 1; giờ làm chủ hành vi trơn trượt nhất của nó — distribution trên union — và những boolean tầng type nhỏ mà mạnh dựng trên đó.

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


Hai sự thật giải thích mọi thứ

Khắc sâu hai điều này — gần như mọi kết quả conditional khó hiểu đều quay về chúng:

  1. Phân phối: khi type được kiểm tra (vế trái extends) là tham số trần là union, conditional chạy một lần cho mỗi phần tử, rồi gộp kết quả.
  2. never là union rỗng: phân phối trên never cho never (lặp 0 lần), và never luôn biến mất khỏi mọi union.

Để tắt distribution, bọc cả hai vế trong tuple 1 phần tử: [T] extends [U].

Cơ chế trong một câu

Conditional type có dạng T extends U ? X : Y. Compiler hỏi: “T có gán được cho U không?” — nếu có, chọn X; nếu không, chọn Y. Phần đó trực quan; cái bẫy là khi T là tham số trần mà lại là union.

Mô hình tưởng tượng ngây thơ (và vì sao nó vỡ)

Bạn có thể nghĩ compiler xử lý string | number như một khối và chạy conditional một lần. Nó không làm vậy — với tham số trần, nó tách union trước:

// naive expectation: one check on the whole union
type NaiveExclude<T, U> = T extends U ? never : T;
// you might guess: MyExclude<'a' | 'b' | 'c', 'a'> → 'b' | 'c'
// reality: distribution does the work member-by-member

Không có distribution, bạn phải tự đệ quy từng phần tử union (mệt). Distribution là map built-in của TypeScript trên union bên trong conditional.

Từng bước cụ thể: MyExclude phân phối trên union

type MyExclude<T, U> = T extends U ? never : T;

type Result = MyExclude<'a' | 'b' | 'c', 'a'>;
// step 1: T is a bare type parameter and T = 'a' | 'b' | 'c' (a union)
//         → compiler distributes: run once per member, union the results
// step 2: member 'a'  → 'a' extends 'a' ? never : 'a'  → never
// step 3: member 'b'  → 'b' extends 'a' ? never : 'b'  → 'b'
// step 4: member 'c'  → 'c' extends 'a' ? never : 'c'  → 'c'
// step 5: union results → never | 'b' | 'c'
// step 6: never vanishes from any union → 'b' | 'c'

Chuỗi sáu bước đó là debugger tưởng tượng bạn cần cho mọi conditional có distribution.

Tắt distribution bằng tuple

Bọc cả hai vế: [T] extends [U]. Tuple ['a' | 'b']một type (tuple một phần tử), không phải union các tuple — nên không tách:

type NoDistribute<T, U> = [T] extends [U] ? true : false;

type A = NoDistribute<'a' | 'b', 'a'>; // false — whole union checked at once
type B = NoDistribute<'a', 'a'>;       // true

Các cạm bẫy

  • Trần vs bọc: T extends U distribute; [T] extends [U], (T extends U ? X : Y), và type cụ thể như string | number extends string thì không.
  • never là union rỗng: never | 'a' rút gọn thành 'a'; phân phối trên never chạy 0 lần → kết quả never.
  • Bất ngờ từ distribution: never extends string ? 1 : 2never, không phải 1 — union rỗng phân phối ra không gì.

Tóm tắt: tham số union trần → tách, chạy conditional từng phần tử, gộp kết quả; [T] extends [U] → một lần kiểm tra trung thực.


268 · If

Một if tầng type: cho boolean C, trả T khi true và F khi false. Nghe đơn giản — nhưng ở tầng type không có từ khóa if, chỉ có extends, và bạn phải chặn người gọi truyền null hay 42 làm điều kiện.

type A = If<true, 'a', 'b'>;  // 'a'
type B = If<false, 'a', 'b'>; // 'b'

Vì sao cách ngây thơ thất bại

// ❌ no constraint — If<null, 'a', 'b'> compiles; runtime logic is undefined
type BadIf<C, T, F> = C extends true ? T : F;

// ❌ checking C extends boolean inside the conditional distributes if C were a union
//    (not an issue for true|false alone, but the pattern is fragile)
type AlsoBad<C, T, F> = C extends boolean ? (C extends true ? T : F) : never;

Cách sửa là ràng buộc generic trên C cộng kiểm tra literal trực tiếp.

Hiện đáp án
type If<C extends boolean, T, F> = C extends true ? T : F;

Từng dòng

  1. chỉ truefalse được truyền; If<null, …> là lỗi biên dịch.
  2. hỏi C có gán được cho literal true không.
  3. khi Ctrue, kiểm tra đúng → T.
  4. khi Cfalse, false không gán được cho trueF.

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

type If<C extends boolean, T, F> = C extends true ? T : F;

type A = If<true, 'a', 'b'>;
// step 1: substitute C=true, T='a', F='b'
// step 2: checked type is literal true (concrete, not a bare union param) → no distribution
// step 3: true extends true ? 'a' : 'b' → true branch → 'a'

type B = If<false, 'a', 'b'>;
// step 1: substitute C=false
// step 2: false extends true ? 'a' : 'b'
// step 3: false is NOT assignable to true → false branch → 'b'

Cạm bẫy

  • boolean (type rộng) không giống true | false cho bài này — người gọi truyền literal true/false.
  • Nếu cho phép C extends boolean | gì đó, distribution có thể lọt vào khi C là union — giữ ràng buộc chặt.

Tóm tắt: ràng buộc C, rồi một lần kiểm tra literal không distribute chọn nhánh.


1042 · IsNever

Trả true khi và chỉ khi type là never. Đây là bài “gotcha” kinh điển — one-liner hiển nhiên trả never thay vì true.

type A = IsNever<never>;       // true
type B = IsNever<undefined>;   // false
type C = IsNever<never | 1>;   // false  (the union is just 1)

Cách ngây thơ (và cái bẫy)

// ❌ looks correct, fails for T = never
type NaiveIsNever<T> = T extends never ? true : false;

type Trap = NaiveIsNever<never>; // never — NOT true!

Khi T = never, vế trái là tham số trần và là union rỗng. Distribution chạy trên 0 phần tử → cả conditional rút gọn về never.

Hiện đáp án
type IsNever<T> = [T] extends [never] ? true : false;

Từng dòng

  1. bọc T trong tuple 1 phần tử để không còn là tham số union “trần”.
  2. mục tiêu so sánh cũng được bọc, hai vế đều là tuple.
  3. khi Tnever, đây là [never] extends [never]true.
  4. khi T là gì khác (vd undefined), [undefined] extends [never] sai → false.

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

type IsNever<T> = [T] extends [never] ? true : false;

type A = IsNever<never>;
// step 1: T = never
// step 2: naive form would distribute over 0 members → never (the trap)
// step 3: wrapped form: [never] extends [never] ? true : false
// step 4: [never] is assignable to [never] → true

type B = IsNever<undefined>;
// step 1: T = undefined
// step 2: [undefined] extends [never] ? true : false
// step 3: undefined is not assignable to never → false branch → false

type C = IsNever<never | 1>;
// step 1: T = never | 1, which collapses to 1 (never vanishes)
// step 2: [1] extends [never] ? true : false
// step 3: 1 is not assignable to never → false

Cạm bẫy

  • never | X luôn rút gọn thành X — nên IsNever<never | 1> kiểm tra 1, không phải union.
  • never extends string ? 1 : 2 cho never vì cùng lý do union rỗng — bọc tuple là cách sửa phổ quát.
  • Đây là cách dùng kinh điển của [T] extends [U] — hãy thuộc.

Tóm tắt: never ở vế trái conditional trần phân phối ra không gì; tuple tắt distribution để so sánh trung thực.


1097 · IsUnion

Trả true nếu type là union (A | B | ...), ngược lại false. Không hiển nhiên vì một phần tử như string và “union một phần tử” trông giống nhau sau khi rút gọn, và never không được tính là union.

type A = IsUnion<string>;          // false
type B = IsUnion<string | number>; // true
type C = IsUnion<string | never>;  // false  (collapses to string)

Vì sao kiểm tra trực tiếp thất bại

// ❌ string extends string | number is true — but so is string | number extends string | number
//    you cannot tell "one member" from "many" without distribution
type NaiveIsUnion<T> = T extends unknown ? (unknown extends T ? false : true) : false;
// still wrong for many edge cases; need the C = T snapshot trick

Ý chính: distribute T trong khi giữ cả union đóng băng trong tham số thứ hai C.

Hiện đáp án
type IsUnion<T, C = T> = [T] extends [never]
  ? false
  : T extends C
    ? [C] extends [T]
      ? false
      : true
    : never;

Từng dòng

  1. chụp ảnh chụp không phân phối của đầu vào trước khi tách.
  2. bảo vệ: never không phải union; không có dòng này, rác từ distribution lọt ra.
  3. T trần distribute trên các phần tử; trong mỗi nhánh, T là một phần tử, C vẫn là cả union.
  4. kiểm tra không distribute: “cả union có nằm gọn trong một phần tử này không?”.
  5. nếu có → chỉ một phần tử (không phải union) → false; nếu không → union thật → true.

Từng bước: IsUnion<string | number>

type IsUnion<T, C = T> = [T] extends [never]
  ? false
  : T extends C
    ? [C] extends [T]
      ? false
      : true
    : never;

type B = IsUnion<string | number>;
// step 1: T = string | number, C = string | number (snapshot)
// step 2: [string | number] extends [never] ? false → not never → continue
// step 3: distribute T extends C over members:
//
//   branch string:
//     string extends (string | number) ? ... → true (string fits in the union)
//     inner: [string | number] extends [string] ? false : true
//            whole union does NOT fit in string alone → true
//
//   branch number:
//     number extends (string | number) ? ... → true
//     inner: [string | number] extends [number] ? false : true
//            whole union does NOT fit in number alone → true
//
// step 4: union branch results → true | true → true

Từng bước: IsUnion<string> (không phải union)

type A = IsUnion<string>;
// step 1: T = string, C = string
// step 2: [string] extends [never] → false, continue
// step 3: distribute — only one member (string):
//     string extends string ? ... → true
//     inner: [string] extends [string] ? false : true
//            whole "union" (just string) DOES fit in string → false branch → false
// step 4: result → false

Cạm bẫy

  • string | never rút gọn thành string trước khi alias chạy — nên IsUnion<string | never> thực ra là IsUnion<string>.
  • Mẹo snapshot C = T giống K = T trong Permutation (Phần 1).
  • Nhận ra mẫu lặp lại: distribute T, giữ cả union trong C, rồi so sánh.

Tóm tắt: một phần tử đã distribute so với cả union đóng băng — nếu cả khối không vừa một phần tử, đó là union thật.


949 · AnyOf

Như Array.prototype.some của JavaScript nhưng cho type: true nếu bất kỳ phần tử nào của tuple là “truthy”. Giá trị falsy ở tầng type là 0, '', false, [], {}, undefined, null. Cái bẫy: bạn cần ngữ nghĩa “có tồn tại” (any), không phải “với mọi” (all) — và conditional trần distribute trên union thực ra làm all.

type A = AnyOf<[1, '', false]>;    // true  (1 is truthy)
type B = AnyOf<[0, '', false]>;    // false (all falsy)
type C = AnyOf<[]>;                // false

Vì sao lặp hoặc kiểm tra ngây thơ thất bại

// ❌ recursion over tuple elements works but is heavy; there is a one-liner
type AnyOfRecursive<T extends readonly unknown[]> =
  T extends [infer H, ...infer R]
    ? H extends Falsy ? AnyOfRecursive<R> : true
    : false;

// the elegant trick: T[number] builds a union, then ONE non-distributing extends check
// accidentally implements "all falsy?" — invert it
Hiện đáp án
type Falsy = 0 | '' | false | [] | Record<string, never> | undefined | null;

type AnyOf<T extends readonly unknown[]> = T[number] extends Falsy
  ? false
  : true;

Từng dòng

  1. union rõ mọi giá trị falsy tầng type; {} mô hình bằng Record<string, never>, [] là tuple rỗng.
  2. truy cập chỉ số trên tuple cho union của mọi type phần tử (mẹo Tuple to Object từ Phần 1).
  3. vế trái là union cụ thể (vd 1 | '' | false), không phải tham số trần → không distribute.
  4. union A | B chỉ gán được cho Falsy khi cả AB đều falsy — nên đây là phép thử “tất cả falsy?”.
  5. đảo ngược: tất cả falsy → false; ít nhất một truthy → union không nằm trọn trong Falsytrue.

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

type AnyOf<T extends readonly unknown[]> = T[number] extends Falsy ? false : true;

type A = AnyOf<[1, '', false]>;
// step 1: T[number] on [1, '', false] → 1 | '' | false
// step 2: (1 | '' | false) extends Falsy ?
// step 3: a union extends Falsy only if EVERY member does
//         1 extends Falsy? no → whole union fails → false branch NOT taken
// step 4: → true (at least one truthy element)

type B = AnyOf<[0, '', false]>;
// step 1: T[number] → 0 | '' | false
// step 2: 0 extends Falsy? yes; '' extends Falsy? yes; false extends Falsy? yes
// step 3: every member is falsy → union extends Falsy → true branch → false

type C = AnyOf<[]>;
// step 1: T[number] on [] → never (no elements)
// step 2: never extends Falsy ? false : true
// step 3: never is assignable to everything, including Falsy → true branch → false

Cạm bẫy

  • Ta muốn không distribute — thử cả union một lần cho ngữ nghĩa “với mọi”, rồi đảo để có “bất kỳ”.
  • tuple rỗng []T[number]nevernever extends Falsy đúng → false (mọi phần tử đều falsy theo nghĩa rỗng).
  • Nếu lỡ viết trong helper mà vế trái là tham số union trần, distribution sẽ phá ngữ nghĩa “all”.

Tóm tắt: gộp tuple thành union, một lần extends Falsy trên cả khối nghĩa là “tất cả falsy?” — đảo lại để có some.


Đồ nghề Equal — cách test thực sự hoạt động

Mọi type-challenge được chấm bằng Expect<Equal<Your, Expected>>. Đáng để hiểu Equal mà nó dựa vào — kiểm tra hai chiều extends quá lỏng cho việc này.

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

type Expect<T extends true> = T;

Vì sao hai chiều extends thất bại

// ❌ too loose — passes when types are not truly identical
type LooseEqual<X, Y> = X extends Y ? (Y extends X ? true : false) : false;

type Bad1 = LooseEqual<any, string>;           // true (any poisons the check)
type Bad2 = LooseEqual<{ a: 1 }, { readonly a: 1 }>; // may pass when tests expect false

any vừa là supertype vừa subtype của mọi thứ; extends cấu trúc bỏ qua khác biệt readonly và optional mà test challenge quan tâm.

Cơ chế: so sánh hàm generic

Equal dựng hai type hàm generic mà kiểu trả về phụ thuộc X so với Y. Hai hàm như vậy chỉ gán được cho nhau khi XY đồng nhất theo quan hệ nội bộ compiler — chặt hơn nhiều so với extends cấu trúc.

Từng dòng

  1. hàm generic (không tham số) mà kiểu trả về là conditional trên TX.
  2. Tương tự cho Y ở vế phải.
  3. extends ngoài hỏi: bản X có gán được cho bản Y không? Chỉ khi XY gây cùng kết quả conditional với mọi T.
  4. alias ràng buộc: chỉ qua khi Equal<…> ra true.

Trực giác từng bước (không phải chứng minh compiler đầy đủ)

// imagine X = string, Y = string
// left:  <T>() => T extends string ? 1 : 2
// right: <T>() => T extends string ? 1 : 2
// → identical function types → extends holds → true

// imagine X = string, Y = number
// left returns 1 when T=string; right returns 2 when T=string
// → different return types for same T → functions not assignable → false

Cạm bẫy

  • Bạn không cần suy ra gadget này — nhận ra khi lời giải “đậu hai chiều extends mà rớt test thật”.
  • Thủ phạm thường gặp: any, readonly vs mutable, optional ? vs bắt buộc, branded type.
  • Equal là phép bằng gần chính xác — đủ cho chấm challenge, không phải API công khai đảm bảo của TypeScript.

Tóm tắt: so sánh hai type hàm generic thay vì extends hai chiều lỏng — vũ khí bí mật của bộ test.


Bài tập

1. Cài Not<C>, And<A, B>, Or<A, B> thành boolean tầng type.

Lời giải
type Not<C extends boolean> = C extends true ? false : true;
type And<A extends boolean, B extends boolean> = A extends true ? B : false;
type Or<A extends boolean, B extends boolean> = A extends true ? true : B;

Mỗi cái là một conditional; ràng buộc đầu vào boolean như If. And/Or And/Or tận dụng short-circuit bằng cách trả toán hạng kia thay vì kiểm tra lại.

Từng bước: And<true, false>

type And<A extends boolean, B extends boolean> = A extends true ? B : false;

type R = And<true, false>;
// step 1: A=true, B=false
// step 2: true extends true ? false : false
// step 3: true branch → B → false

Từng bước: Or<false, true>

type Or<A extends boolean, B extends boolean> = A extends true ? true : B;

type R = Or<false, true>;
// step 1: A=false, B=true
// step 2: false extends true ? true : true
// step 3: false branch → B → true

Tóm tắt: ba one-liner — cùng mẫu If, short-circuit mã hoá ở nhánh trả toán hạng nào.

2. type X = string extends never ? 1 : 2type Y = never extends string ? 1 : 2 cho kết quả gì?

Lời giải
type X = string extends never ? 1 : 2; // 2
type Y = never extends string ? 1 : 2; // never

X2string không gán được cho never — vế trái cụ thể, không distribute. Ynevernever ở vế trái là union rỗng, conditional phân phối trên 0 phần tử và cho never (không cho 1).

Từng bước: Y

type Y = never extends string ? 1 : 2;
// step 1: left side is never (empty union)
// step 2: if this were T extends string inside a generic, T=never would distribute
// step 3: distribute over 0 members → no branches produce a result
// step 4: union of zero results → never
// (concrete never on the left still follows the same empty-union rule)

Chính vì vậy IsNever cần mẹo dấu ngoặc.

Nâng cao:cài Includes<T, U> cho tuple bằng Equal và distribution — trả true nếu phần tử nào bằng U. (Ta xem lại ở Phần 5.)


Điểm chính

  • Conditional distribute chỉ khi vế trái là tham số trần và là union.
  • Bọc [T] extends [U] để tắt distribution — thiết yếu cho IsNever và phát hiện union.
  • neverunion rỗng; nó biến mất khỏi union và làm distribution trần rút gọn.
  • Mẫu “distribute T, giữ cả union trong C, so sánh” phát hiện union và hơn nữa.
  • Equal của bộ test là phép bằng chặt, gần chính xác, dựng từ so sánh hàm generic.

Tiếp theo

Phần 4 — Làm chủ infer: khớp mẫu và bắt các mảnh của một type. Ta dựng lại ReturnType, Parameters, Last, Pop, học infer có ràng buộc (infer N extends number) để bóc số từ string, và thêm tham số vào type hàm.