联合类型在类型编程中是比较特殊的,TypeScript 对它做了专门的处理,写法上可以简化,但也增加了一些认知成本。
这是类型体操的第五个套路:联合分散可简化。
分布式条件类型
当类型参数为联合类型,并且在条件类型左边直接引用该类型参数的时候,TypeScript 会把每一个元素单独传入来做类型运算,最后再合并成联合类型,这种语法叫做分布式条件类型。
比如这样一个联合类型:
type Union = 'a' | 'b' | 'c';
我们想把其中的 a 大写,就可以这样写:
type UppercaseA<Item extends string> =
Item extends 'a' ? Uppercase<Item> : Item;
可以看到,我们类型参数 Item 约束为 string,条件类型的判断中也是判断是否是 a,但传入的是联合类型。
这就是 TypeScript 对联合类型在条件类型中使用时的特殊处理:会把联合类型的每一个元素单独传入做类型计算,最后合并。
这和联合类型遇到字符串时的处理一样:
这样确实是简化了类型编程逻辑的,不需要递归提取每个元素再处理。
TypeScript 之所以这样处理联合类型也很容易理解,因为联合类型的每个元素都是互不相关的,不像数组、索引、字符串那样元素之间是有关系的。所以设计成了每一个单独处理,最后合并。
知道了 TypeScript 怎么处理的联合类型,趁热打铁来练习一下
CamelcaseUnion
Camelcase 我们实现过,就是提取字符串中的字符,首字母大写以后重新构造一个新的。
type Camelcase<Str extends string> =
Str extends `${infer Left}_${infer Right}${infer Rest}`
? `${Left}${Uppercase<Right>}${Camelcase<Rest>}`
: Str;
type Test = Camelcase<'hello_world_example'>; // 结果是 'helloWorldExample'
提取 _ 左右的字符,把右边字符大写之后构造成新的字符串,余下的字符串递归处理。
联合类型不需要递归提取每个元素,TypeScript 内部会把每一个元素传入单独做计算,之后把每个元素的计算结果合并成联合类型。
type CamelcaseUnion<Item extends string> =
Item extends `${infer Left}_${infer Right}${infer Rest}`
? `${Left}${Uppercase<Right>}${CamelcaseUnion<Rest>}`
: Item;
1. 条件类型 Item extends ...
这个条件类型检查 Item
是否符合 ${infer Left}_${infer Right}${infer Rest}
的模式。具体来说,它会尝试将 Item
分解为三部分:
Left
:下划线_
前的部分。Right
:紧跟在下划线_
后的第一个字符。Rest
:下划线_
后除第一个字符外的剩余部分。
2. 分支 ? ... : ...
如果 Item
符合上述模式,则执行 true
分支;否则,执行 false
分支。
-
True 分支:
Typescript 深色版本 `${Left}${Uppercase<Right>}${CamelcaseUnion<Rest>}`
Left
:保留原样。Uppercase<Right>
:将Right
转换为大写。CamelcaseUnion<Rest>
:对剩余部分Rest
递归调用CamelcaseUnion
类型别名,继续处理剩余的字符串。
-
False 分支:
Typescript 深色版本 Item
如果
Item
不包含下划线_
,则直接返回Item
,不做任何修改。
// 联合类型的测试
type TestUnion = CamelcaseUnion<'one_two' | 'three_four'>; // 应该是 'oneTwo' | 'threeFour'
判断联合类型我们会这样写:
type IsUnion<A, B = A> =
A extends A
? [B] extends [A]
? false
: true
: never
1. 参数说明
A
:要检测的类型。B
:默认值为A
,用于后续比较。
2. 条件类型 A extends A
这一步总是为 true
,因为任何类型都扩展自身。因此,这个条件实际上是为了触发后面的条件判断。
3. 内部条件 [B] extends [A]
这是关键的检测步骤:
[B]
是一个包含B
的元组类型。[A]
是一个包含A
的元组类型。- 如果
[B]
可以被[A]
扩展(即[B]
的所有成员都可以赋值给[A]
的相应成员),则认为B
是A
的子类型。
具体逻辑
- 如果
[B]
可以被[A]
扩展,说明B
和A
是相同的类型,或者B
是A
的子类型。在这种情况下,A
不是联合类型。 - 如果
[B]
不能被[A]
扩展,说明B
和A
不完全相同,这意味着A
是一个联合类型。
4. 分支 ? false : true
- 如果
[B]
可以被[A]
扩展,则返回false
,表示A
不是联合类型。 - 如果
[B]
不能被[A]
扩展,则返回true
,表示A
是联合类型。
5. 默认值 never
如果 A
不能扩展自身(理论上不可能发生),则返回 never
。
当传入联合类型时,会返回 true:
type Result4 = IsUnion<string | number>; // true
type Result5 = IsUnion<'a' | 'b' | 'c'>; // true
type Result6 = IsUnion<{ a: number } | { b: string }>; // true
当传入其他类型时,会返回 false:
type Result1 = IsUnion<string>; // false
type Result2 = IsUnion<number>; // false
type Result3 = IsUnion<{ a: number }>; // false
是不是在心里会问:什么鬼?这段逻辑是啥?
这就是分布式条件类型带来的认知成本。
我们先来看这样一个类型:
type TestUnion<A, B = A> = A extends A ? { a: A, b: B} : never;
type TestUnionResult = TestUnion<'a' | 'b' | 'c'>;
传入联合类型 'a' | 'b' | 'c' 的时候,结果是这样的:
A 和 B 都是同一个联合类型,为啥值还不一样呢?
因为条件类型中如果左边的类型是联合类型,会把每个元素单独传入做计算,而右边不会。
所以 A 是 'a' 的时候,B 是 'a' | 'b' | 'c', A 是 'b' 的时候,B 是 'a' | 'b' | 'c'。。。
那么利用这个特点就可以实现 Union 类型的判断:
type IsUnion<A, B = A> =
A extends A
? [B] extends [A]
? false
: true
: never
类型参数 A、B 是待判断的联合类型,B 默认值为 A,也就是同一个类型。
A extends A 这段看似没啥意义,主要是为了触发分布式条件类型,让 A 的每个类型单独传入。
[B] extends [A] 这样不直接写 B 就可以避免触发分布式条件类型,那么 B 就是整个联合类型。
B 是联合类型整体,而 A 是单个类型,自然不成立,而其它类型没有这种特殊处理,A 和 B 都是同一个,怎么判断都成立。
利用这个特点就可以判断出是否是联合类型。
其中有两个点比较困惑,我们重点记一下:
当 A 是联合类型时:
- A extends A 这种写法是为了触发分布式条件类型,让每个类型单独传入处理的,没别的意义。
- A extends A 和 [A] extends [A] 是不同的处理,前者是单个类型和整个类型做判断,后者两边都是整个联合类型,因为只有 extends 左边直接是类型参数才会触发分布式条件类型。
理解了这两点,分布式条件类型就算掌握了。
BEM
bem 是 css 命名规范,用 block__element--modifier 的形式来描述某个区块下面的某个元素的某个状态的样式。
那么我们可以写这样一个高级类型,传入 block、element、modifier,返回构造出的 class 名:
这样使用:
type bemResult = BEM<'guang', ['aaa', 'bbb'], ['warning', 'success']>;
结果:
type bemResult = 'guang__aaa--warning' | 'guang__aaa--success' | 'guang__bbb--warning' | 'guang__bbb--success';
假设我们要创建一个 BEM
类型别名,它接受三个参数:
Block
:块名(字符串)。Elements
:元素名数组(字符串数组)。Modifiers
:修饰符名数组(字符串数组)。
我们希望生成的类名格式如下:
- 块名:
block
- 元素名:
block__element
- 修饰符:
block--modifier
- 元素 + 修饰符:
block__element--modifier
它的实现就是三部分的合并,但传入的是数组,要递归遍历取出每一个元素来和其他部分组合,这样太麻烦了。
而如果是联合类型就不用递归遍历了,因为联合类型遇到字符串也是会单独每个元素单独传入做处理。
type BEM<
Block extends string,
Element extends string[],
Modifiers extends string[]
> = `${Block}__${Element[number]}--${Modifiers[number]}`;
类型参数 Block、Element、Modifiers 分别是 bem 规范的三部分,其中 Element 和 Modifiers 都可能多个,约束为 string[]。
构造一个字符串类型,其中 Element 和 Modifiers 通过索引访问来变为联合类型。
总结
联合类型中的每个类型都是相互独立的,TypeScript 对它做了特殊处理,也就是遇到字符串类型、条件类型的时候会把每个类型单独传入做计算,最后把每个类型的计算结果合并成联合类型。
条件类型左边是联合类型的时候就会触法这种处理,叫做分布式条件类型。
有两点特别要注意:
- A extends A 不是没意义,意义是取出联合类型中的单个类型放入 A
- A extends A 才是分布式条件类型, [A] extends [A] 就不是了,只有左边是单独的类型参数才可以。
我们后面做了一些案例,发现联合类型的这种 distributive 的特性确实能简化类型编程,但是也增加了认知成本,不过这也是不可避免的事。