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:
- Phân phối: khi type được kiểm tra (vế trái
extends) là tham số trần và là union, conditional chạy một lần cho mỗi phần tử, rồi gộp kết quả. neverlà union rỗng: phân phối trênneverchonever(lặp 0 lần), vàneverluô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'] là 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 Udistribute;[T] extends [U],(T extends U ? X : Y), và type cụ thể nhưstring | number extends stringthì không. neverlà union rỗng:never | 'a'rút gọn thành'a'; phân phối trênneverchạy 0 lần → kết quảnever.- Bất ngờ từ distribution:
never extends string ? 1 : 2lànever, không phải1— 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
- chỉ
truevàfalseđược truyền;If<null, …>là lỗi biên dịch. - hỏi
Ccó gán được cho literaltruekhông. - khi
Clàtrue, kiểm tra đúng →T. - khi
Clàfalse,falsekhông gán được chotrue→F.
Đá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ốngtrue | falsecho bài này — người gọi truyền literaltrue/false.- Nếu cho phép
C extends boolean | gì đó, distribution có thể lọt vào khiClà 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
- bọc
Ttrong tuple 1 phần tử để không còn là tham số union “trần”. - mục tiêu so sánh cũng được bọc, hai vế đều là tuple.
- khi
Tlànever, đây là[never] extends [never]→true. - khi
Tlà gì khác (vdundefined),[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 → falseCạm bẫy
never | Xluôn rút gọn thànhX— nênIsNever<never | 1>kiểm tra1, không phải union.never extends string ? 1 : 2chonevervì 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
- chụp ảnh chụp không phân phối của đầu vào trước khi tách.
- bảo vệ:
neverkhông phải union; không có dòng này, rác từ distribution lọt ra. Ttrần distribute trên các phần tử; trong mỗi nhánh,Tlà một phần tử,Cvẫn là cả union.- kiểm tra không distribute: “cả union có nằm gọn trong một phần tử này không?”.
- 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 → trueTừ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 → falseCạm bẫy
string | neverrút gọn thànhstringtrước khi alias chạy — nênIsUnion<string | never>thực ra làIsUnion<string>.- Mẹo snapshot
C = TgiốngK = Ttrong Permutation (Phần 1). - Nhận ra mẫu lặp lại: distribute
T, giữ cả union trongC, 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
- union rõ mọi giá trị falsy tầng type;
{}mô hình bằngRecord<string, never>,[]là tuple rỗng. - 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).
- vế trái là union cụ thể (vd
1 | '' | false), không phải tham số trần → không distribute. - union
A | Bchỉ gán được choFalsykhi cảAvàBđều falsy — nên đây là phép thử “tất cả falsy?”. - đảo ngược: tất cả falsy →
false; ít nhất một truthy → union không nằm trọn trongFalsy→true.
Đá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 → falseCạ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]lànever→never 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 X và Y đồ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
- hàm generic (không tham số) mà kiểu trả về là conditional trên
TvàX. - Tương tự cho
Yở vế phải. extendsngoài hỏi: bảnXcó gán được cho bảnYkhông? Chỉ khiXvàYgây cùng kết quả conditional với mọiT.- alias ràng buộc: chỉ qua khi
Equal<…>ratrue.
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,readonlyvs mutable, optional?vs bắt buộc, branded type. Equallà 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 → falseTừ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 → trueTó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 : 2 và type 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; // neverX là 2 vì string không gán được cho never — vế trái cụ thể, không distribute. Y là never vì never ở 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 choIsNevervà phát hiện union. neverlà union 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 trongC, so sánh” phát hiện union và hơn nữa. Equalcủ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.