Typescript进阶之类型体操套路五

168 阅读7分钟

联合类型在类型编程中是比较特殊的,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 类型别名,它接受三个参数:

  1. Block:块名(字符串)。
  2. Elements:元素名数组(字符串数组)。
  3. 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 的特性确实能简化类型编程,但是也增加了认知成本,不过这也是不可避免的事。

本文案例的合并