你不知道的 TypeScript:联合类型与分布式条件类型
在 TypeScript 中,联合类型是非常常用的类型工具,但联合类型在条件类型中的分布式特性,估计会困扰很多人,因为它的行为非常的…不直观。
所以本文尽量用简单的语言和丰富的例子来让大家彻底搞懂联合类型与分布式条件类型,搞不懂也别打我。
联合类型
联合类型(Union Types) 表示一个值可以是几种类型之一。用竖线 | 来分隔每个类型,例如 string | number 表示一个值可以是 string 或 number。
联合类型的基础用法:
// 表示类型可以为 string 或 number
type StringOrNumber = string | number;
let value: StringOrNumber;
value = "hello"; // ✅
value = 42; // ✅
value = true; // ❌ 不能将类型“boolean”分配给类型“StringOrNumber”
// 多个类型的联合
type ID = string | number | symbol;
// 字面量类型的联合
type Status = "pending" | "success" | "error";
type StyleType = 1 | 2 | 3;
分布式条件类型
什么是条件类型?
条件类型(Conditional Types)的语法类似于 JavaScript 中的三元运算符:
SomeType extends OtherType ? TrueType : FalseType;
它表示:当 extends 左边的类型 SomeType 可以赋值给右侧的类型 OtherType 时,返回 TrueType,否则返回 FalseType。
用法示例:
type A = string extends 'string' ? true : false // false
type B = 'string' extends string ? true : false // true
type C = number extends number ? boolean : string // boolean
实际应用中,条件类型常配合泛型一起使用:
// 判断一个类型是否为字符串类型
type IsString<T> = T extends string ? true : false;
type A = IsString<string>; // true
type B = IsString<number>; // false
// 提取函数的返回类型
type MyReturnType<T> = T extends (...args: any[]) => infer R ? R : never;
type R = MyReturnType<() => string>; // string
什么是分布式条件类型?
When conditional types act on a generic type, they become distributive when given a union type.
分布式条件类型(Distributive Conditional Types) 是指:当条件类型作用于泛型类型时,如果该泛型类型为联合类型,则条件类型将具有 分布性。
简单来说,就是条件类型会分别对联合类型的每个成员进行判断和处理,然后将所有结果重新组合成一个联合类型。我愿意把这理解为 TypeScript 类型系统中的 "forEach"。
假设我们有下面的类型 ToArray,可以看到它会把联合类型泛型当做一个整体处理。
type ToArray<T> = T[]
type Result = ToArray<string | number>; // (string | number)[]
而如果我们使用条件类型,可以看到此时联合类型的每一个成员会分别应用于条件类型。
type ToArray<T> = T extends any ? T[] : never;
// 当 T 是联合类型时
type Result = ToArray<string | number>; // string[] | number[]
// 分布过程:
// ToArray<string | number>
// => ToArray<string> | ToArray<number>
// => (string extends any ? string[] : never) | (number extends any ? number[] : never)
// => string[] | number[]
这里 T extends any 几乎永远为真(除了 T 为 never 的特殊情况),其主要作用是触发分布式条件类型。我们也可以写成 T extends unknown 或 T extends T 等形式来保证条件为真。
但是必须要注意一点:extends 的左边必须为单独的 T。
阻止分布式行为
在某些场景下,我们希望在条件类型中使用联合类型,但不希望触发分布式行为,此时可以用方括号 [] 将 extends 关键字两侧的类型包裹起来。
type ToArrayNonDist<Type> = [Type] extends [any] ? Type[] : never;
// 此时 ArrOfStrOrNum 不再是一个联合类型
type ArrOfStrOrNum = ToArrayNonDist<string | number>; // (string | number)[]
我们将 extends 左边单独的类型参数(比如前面的 T)称为 Naked Type (裸类型,一般指没有被包装的类型)。只有 Naked Type 才会触发分布式条件类型,当它不再是 Naked Type 就不会触发分布式。
事实上,任何形式的包装都可以阻止分布式,例如:
-
[T] extends [any]- 用元组包装 -
Array<T> extends Array<any>- 用泛型包装 -
{ value: T } extends { value: any }- 用对象类型包装
只要 extends 左边不是单独的联合类型,就不会触发分布式行为。
分布式条件类型简单实例
Exclude
实现 TypeScript 内置的 Exclude<T, U> 类型:从联合类型 T 中排除 U 中的类型,来构造一个新的类型。
利用分布式条件类型的特性,依次判断联合类型 T 的每个成员是否可以赋值给 U。如果可以赋值,则返回 never(将其从结果中剔除);否则返回该成员本身(将其保留)。
type MyExclude<T, U> = T extends U ? never : T;
type T1 = MyExclude<"a" | "b" | "c", "a">; // "b" | "c"
type T2 = MyExclude<string | number | boolean, string>; // number | boolean
Extract
实现 TypeScript 内置的 Extract<T, U> 类型:从联合类型 T 中提取 U 中的类型,来构造一个新的类型。
与 Exclude 的逻辑完全相反:如果可以赋值给 U,则保留该成员;否则返回 never 将其剔除。
type MyExtract<T, U> = T extends U ? T : never;
type T3 = MyExtract<"a" | "b" | "c", "a" | "b">; // "a" | "b"
type T4 = MyExtract<string | number | boolean, number | boolean>; // number | boolean
Flatten
把联合类型中的每个数组元素都展平。
type Flatten<T> = T extends (infer U)[] ? U : T;
type Nested = string[] | number[];
type Flattened = Flatten<Nested>; // string | number
实战:实现 Permutation(全排列)
ok,学完 1+1,接下来我们开始学微积分了,笑:)
实现联合类型的全排列,将联合类型转换成所有可能的全排列数组的联合类型。
type perm = Permutation<'A' | 'B' | 'C'>; // ['A', 'B', 'C'] | ['A', 'C', 'B'] | ['B', 'A', 'C'] | ['B', 'C', 'A'] | ['C', 'A', 'B'] | ['C', 'B', 'A']
这是一道典型的分布式条件类型应用题。(谁会知道我为了这道醋包了这篇饺子)。
全排列问题的核心思路是递归(建议先理解 JavaScript 中的全排列实现)。
以 'A' | 'B' | 'C' 为例,递归的思路是:
- 枚举第一个位置的元素(
A、B或C) - 对剩余元素递归求全排列
- 将当前元素与剩余元素的全排列组合
具体来说:
- 当第一个元素为
A时,剩余元素为B | C,递归计算Permutation<'B' | 'C'> - 当第一个元素为
B时,剩余元素为A | C,递归计算Permutation<'A' | 'C'> - 当第一个元素为
C时,剩余元素为A | B,递归计算Permutation<'A' | 'B'>
这里的重点是:利用分布式条件类型会自动遍历联合类型的每个成员的特点,枚举首元素。
难点在于:如何获取"除当前元素外的剩余元素"?我们需要一个额外的泛型参数 U 来保存完整的联合类型。当分布式条件类型遍历时,T 是当前元素,U 是完整的联合类型,此时使用 Exclude<U, T> 就能得到剩余元素。
初步实现如下:
type Permutation<T, U = T> =
T extends any
? [T, ...Permutation<Exclude<U, T>>]
: []
type R = Permutation<'A' | 'B' | 'C'>;
不幸的是,上面的实现会得到 never。原因是递归缺少终止条件。
当递归到最后,T 会变成空的联合类型(即 never),但我们没有特殊处理终止条件,只能得到 never。
我们需要添加一个终止条件判断:当 T 为空集时,不需要再处理直接返回 []。
判断空集的条件是 [T] extends [never]。这里将 T 包裹在元组中,阻止了分布式行为,使其变成普通的类型检查。只有当 T 为空集时,这个条件才为真。
正确答案:
/**
* 生成联合类型的所有排列组合
*/
type Permutation<T, U = T> =
[T] extends [never]
? []
: T extends U
? [T, ...Permutation<Exclude<U, T>>]
: never;
type P1 = Permutation<"a" | "b" | "c">;
// => ["a", "b", "c"] | ["a", "c", "b"] | ["b", "a", "c"] | ["b", "c", "a"] | ["c", "a", "b"] | ["c", "b", "a"]