类型体操 - 用 TS 类型写一套流程控制语句

1,166 阅读9分钟

前言

ts 类型体操的文章网上已经非常多了,比如《用 TypeScript 类型运算实现一个中国象棋程序》《TypeScript 类型体操天花板,用类型运算写一个 Lisp 解释器》,可以说已经到了天花板级别的类型体操了。而这篇文章其实并不会涉及那么复杂的类型体操,仅仅是希望通过它来帮助你更好地了解一个复杂类型内部的逻辑层级,就当是对之前一篇 TypeScript 实践指南(万字长文)的补充了。

在这之前我想说一下,ts 本身主要发展的领域并不需要太过于复杂的逻辑需求,如果你能看懂之前那篇 ts 的实践文章所举的例子,说明你的 ts 基本功已经非常不错了。而前面提到的两篇文章中所用到的类型在大多数情况下并不会使用,因为 ts 引入的主要目的是在帮助我们进行类型推断,而不是用来做纯粹的逻辑计算。 你的重心应该是学习它们类型编写思路或者练习你的类型编程能力。

所以,我写类型体操相关文章的目的更多是为了启发你在实际项目中的灵感,不要太过于重视“体操”而丢失了其原本的意义。

准备工作

在一切开始之前,我们需要先写两个最基础的条件控制语句IfExtendsIsExtends

  • IfExtends:判断 Condition 元组中的两个值是否符合Condition[0] extends Condition[1],如果成功返回 Case1,否则返回 Case2。
    type IfExtends<Condition extends [any, any], Case1, Case2> = [
      Condition[0]
    ] extends [Condition[1]]
      ? Case1
      : Case2
    
    使用方式:
    IfExtends<[1, number], true, false> // true
    
  • IsExtends:判断 A 是否 extends B,但是去除了条件类型的影响。成功返回true,失败返回false
    type IsExtends<A, B> = [A] extends [B] ? true : false
    
    使用方式:
    IsExtends<1, number> // true
    

然后,我们定义了一些规则来辅助我们:

// 我们规定以下值为含有 falsy 意义的值(注意,Falsy 类型只是用于展示规则,不要使用它)
type Falsy = 0 | false | '' | undefined | null | void | never | unknown
// 当一个类型为联合类型时,当所有元素都为 falsy 时它的值才是 falsy,否则为 truthy
// 比如:0 | false 就是 falsy 值,而 0 | false | 'true' 就是 truthy 值

// 因为 unknown 的特殊性,它本身兼容其余的类型,比如上面的 Falsy 类型最后的结果是 unknown 类型,所以我们在做一些操作时需要单独将其提出再判断
type FalsyWithoutUnknown =
  | 0
  | false
  | ''
  | undefined
  | null
  | void
  | never

最后,基于IfExtends以及刚刚我们定义的规则,我们创建出了一些辅助类型:

// 0 & any => any, 0 & unknown => 0, 0 & number => 0,0 & other => never
type IsAny<T> = IsExtends<number, 0 & T>

// 任何值都可以 extends unknown, unknown 只能 extends unknown 和 any,后续将 any 排除掉
type IsUnknown<T> = IfExtends<
  [unknown, T],
  IfExtends<[IsAny<T>, true], false, true>,
  false
>

// 只有 never 才可以 extends never
type IsNever<T> = IsExtends<T, never>

// 先判断是否为 never,因为 never 进入判断会有意料之外的结果
type IsFalsy<T> = IfExtends<
  [T, never],
  true,
  IfExtends<
    [T, FalsyWithoutUnknown],
    // 如果满足 falsy 值,去除掉 any 的影响,因为 any extends 任何值
    IfExtends<[IsAny<T>, true], false, true>,
    // 不满足 falsy 值,再判断一下是否是剩余的 unknown
    IsUnknown<T>
  >
>

// IsFalsy 取反
type IsTruthy<T> = IfExtends<[IsFalsy<T>, true], false, true>

流程控制语句

If

使用方式:

If<Condition, Case1, Case2>

If<true, 1, 2> // 1
If<false, 1, 2> // 2

由之前写的IfExtends很轻松就可以推测出If的书写方式。

type If<Condition, Case1, Case2> = IfExtends<
  [IsTruthy<Condition>, true],
  Case1,
  Case2
>

IfElseIf

IfElseIf我们需要引入数组来辅助类型计算。

使用方式:

IfElseIf<[[Condition1, Result1], [Condition2, Result2], [Condition3, Result3], Result4]>

IfElseIf<[[true, 1], 3]> // 1
IfElseIf<[[true, 1], [true, 2], 3]> // 1
IfElseIf<[[false, 1], [true, 2], 3]> // 2
IfElseIf<[[false, 1], [0, 2], 3]> // 3

为什么要引入数组?

因为else if可以有无数分支,我们无法预知具体的参数数量,所以我们不能单纯给类型加入泛型参数,应该使用元组作为泛型来取而代之(元组可以依次获取到每一个元素的类型)。

为了书写方便,这里我们再次引入数组与元组相关的工具类型来辅助操作:

// 创建同时兼容 readonly 版本的数组
type ArrayAndReadonlyArrayByPassArray<
  T extends any[] | readonly any[] = any[]
> = T | Readonly<T>
type ArrayAndReadonlyArrayByPassItem<T = any> = T[] | readonly T[]

// 是否是空数组,注意这里 T extends T 代表运用了联合类型与条件类型的特性,会分别计算联合类型的值
export type IsEmptyTypeArray<T extends ArrayAndReadonlyArrayByPassArray> =
  T extends T ? IsExtends<T['length'], 0> : never

// 获取数组的元素类型
type ArrayItem<T extends ArrayAndReadonlyArrayByPassItem> =
  T extends ArrayAndReadonlyArrayByPassItem<infer Item> ? Item : never

// 元组类型
type Tuple<T = any, R = T> = ArrayAndReadonlyArrayByPassArray<
  [T, ...R[]] | [...R[], T]
>

// 判断是否是元组类型,这里空数组没有被算作是元组
type IsTuple<T extends ArrayAndReadonlyArrayByPassArray> = IsExtends<
  T,
  Tuple
>

下面是具体的代码实现:

export type IfElseIf<
  A extends ArrayAndReadonlyArrayByPassArray<
    // if/else if/else if/else else
    [[Condition: any, Result: any], ...[Condition: any, Result: any][], any]
  >
> = A extends ArrayAndReadonlyArrayByPassArray<
  // 将拿到的元组依次解构,拿到每一个表达式元组(此时是二位数组),ElseIfExpressions 是所有剩余带有分支条件的集合
  [infer IfExpression, ...infer ElseIfExpressions, infer ElseResult]
>
    // 每一个表达式解构出判断条件以及对应的结果
  ? IfExpression extends [infer IfCondition, infer IfResult]
      // 使用上面写好的 If
    ? If<
        IfCondition,
        IfResult,
        // 如果当前表达式判断条件失败,开始走下一个 if else 分支
        If<
          // 如果剩余的分支条件依旧是元组,因为可能整个表达式是 [[false, 1], ...[true, 2][], 3],或者已经没有分支条件了,为空数组
          IsTuple<ElseIfExpressions>,
          // 满足条件,再次解析下一个条件分支
          ElseIfExpressions extends [
            [infer ElseIfCondition, infer ElseIfResult],
            ...infer OtherElseIfExpressions
          ]
            ? OtherElseIfExpressions extends [Condition: any, Result: any][]
            // 下面开始递归
              ? IfElseIf<
                  [
                    // 下一个分支条件变为第一个,后续为剩余参数,ElseResult 原样填写
                    [ElseIfCondition, ElseIfResult],
                    ...OtherElseIfExpressions,
                    ElseResult
                  ]
                >
                // 不符合 OtherElseIfExpressions extends [Condition: any, Result: any][],判断完当前值就返回
              : If<ElseIfCondition, ElseIfResult, ElseResult>
            // 不符合 ElseIfExpressions extends [[infer ElseIfCondition, infer ElseIfResult], ...infer OtherElseIfExpressions],一般是无法进入此分支的,这里做个占位
            : ElseResult,
          // 如果 ElseIfExpressions 不是元组,那么为数组或者空数组
          // empty array or array
          ElseIfExpressions extends [
            infer ElseIfCondition,
            infer ElseIfResult
          ][]
            // 如果是数组,那么条件和结果都是相同的,我们不需要递归了
            ? If<ElseIfCondition, ElseIfResult, ElseResult>
            // 如果不满足数组条件,直接返回 ElseResult
            : ElseResult
        >
      >
    : // 一般无法进入此分支,做个 feedback 占位
      ElseResult
  : never

Not

Not类似 JS 中的!(取反)语法,当传入参数为Truthy时返回false,反之则返回true

使用方式:

Not<Condition>

Not<false> // true
Not<1> // false
Not<number> // false
Not<1 | 0> // false

代码实现:

// 因为 IsFalsy 的判断条件少一点,所以这里通过判断 IsFalsy 再取反
type Not<T> = If<IsFalsy<T>, true, false>

Or

Or类似 JS 中的||语法,这里也是使用数组作为参数传入,当数组内所有元素均为Falsy时返回false,否则返回true

使用方式:

Or<[Condition1, Condition2, Condition3]>

Or<[true, false]> // true
Or<[1, false]> // true
Or<[0, false, '']> // false
Or<[0, false, '', 2]> // true
Or<[0, false, '', ...number[]]> // true

代码实现:

type Or<A extends ArrayAndReadonlyArrayByPassArray> = If<
  // 先整体判断 A 的值是否为元组
  IsTuple<A>,
  // 使用 extends + infer 拿到当前判断条件 Current 以及剩余的判断条件 Rest
  A extends ArrayAndReadonlyArrayByPassArray<[infer Current, ...infer Rest]>
    // 这里递归判断每一个条件,如果 Current 为 Truthy 就返回 true
    ? If<Current, true, Or<Rest>>
    // 因为我们在参数传递位置并没有像 IfElseIf 那样约束参数,所以这里我们是需要判断两种元组情况的,[1, ...number[]] 和 [...number[], 1] 都属于元组,但是第一种判断条件只能拿到第一种元组的值
    : A extends ArrayAndReadonlyArrayByPassArray<[...infer Rest, infer Current]>
    // 剩下的判断条件同第一种情况
    ? If<Current, true, Or<Rest>>
    // 如果都不能匹配证明不属于元组,返回 never 代表解析错误
    : never,
  // 如果 A 不为元组,则 A 的值为数组或者空数组,直接判断内部元素类型,空数组为 undefined
  IsTruthy<ArrayItem<A>>
>

And

And则类似 JS 中的&&语法,使用方式大体同Or,当数组内所有元素均为Truthy时返回true,否则返回false

使用方式:

And<[Condition1, Condition2, Condition3]>

And<[true, false]> // false
And<[1, true]> // true
And<[1, true, '']> // false
And<[1, true, '2']> // true
And<[1, true, ...number[]]> // true

代码实现:

export type And<A extends ArrayAndReadonlyArrayByPassArray> = If<
  IsTuple<A>,
  // 使用 extends + infer 拿到当前判断条件 Current 以及剩余的判断条件 Rest
  A extends ArrayAndReadonlyArrayByPassArray<[infer Current, ...infer Rest]>
    ? If<
        Current,
        // 如果当前判断条件为真,继续判断后续条件
        If<
          // 判断 Rest 的类型
          IsTuple<Rest>,
          // 如果 Rest 为元组则递归判断
          And<Rest>,
          // 这里我们使用了刚刚的 Or 来帮助计算。
          // 如果 Rest 为空数组,证明没有后续了,返回 true。如果 Rest 为普通数组(非元组),只需要判断该数组的元素类型是否为 Truthy 就行了
          Or<[IsEmptyTypeArray<Rest>, IsTruthy<ArrayItem<Rest>>]>
        >,
        // 如果当前判断条件为假,直接返回 false
        false
      >
    // 同 Or,需要在两边同时判断
    : A extends ArrayAndReadonlyArrayByPassArray<[...infer Rest, infer Current]>
    ? If<
        Current,
        If<
          IsTuple<Rest>,
          And<Rest>,
          Or<[IsEmptyTypeArray<Rest>, IsTruthy<ArrayItem<Rest>>]>
        >,
        false
      >
    : never,
  // 如果 A 不为元组,则 A 的值为数组或者空数组,直接判断内部元素类型,空数组为 undefined
  IsTruthy<ArrayItem<A>>
>

Switch

Switch的设计思路其实同IfElseIf类似,需要注意的是每个分支的比较变为了具体的值而不是判断是否为Truthy(这点等同于 JS 的switch语句)。

为了能够具备更好的扩展性,这里让Switch具备了两种比较模式,一种是全等比较(类型完全相等),一种是继承比较(只需要满足继承关系即可),默认条件下使用继承比较。

在这之前,我们需要定义出这两种标识:

// 这里直接用字符串标识,用户可以在使用时直接写字符串
type EqualTag = 'equal'
type ExtendsTag = 'extends'

以及两个标识分别对应的比较方法:

// IsExtends 在之前我们有提到过
type IsExtends<A, B> = [A] extends [B] ? true : false

// 判断两个类型是否相等,这个具体的实现原因比较复杂,涉及 ts 的内部解析机制,可以理解成固定模板
// 当然,如果确实想要了解可以看看这个 issue: https://github.com/microsoft/TypeScript/issues/27024
type IsEquals<A, B> = (<T>() => T extends A ? 1 : 2) extends <
  T
>() => T extends B ? 1 : 2
  ? true
  : false

下面是Switch的使用方式:

// Switch 一定会有默认值
Switch<Value, [[Case1, Result1], [Case2, Result2], DefaultResult], Type extends EqualTag | ExtendsTag = ExtendsTag>

Switch<1, [[number, 'result1'], [1, 'result2'], 'defaultResult']> // result1,默认使用 extends,1 extends number,所以返回的 result1
Switch<1, [[number, 'result1'], [1, 'result2'], 'defaultResult'], EqualTag> // result2,因为是使用的全等,1 并不全等于 number,所以返回 result2
Switch<1, [[number, 'result1'], [2, 'result2'], 'defaultResult'], 'equal'> // defaultResult,所有值都不匹配,返回默认值

类型实现:

type Switch<
  T,
  A extends ArrayAndReadonlyArrayByPassArray<
    // if/else if/else
    // 我们这里直接约束了传入参数必须为元组,所以不需要二次判断了
    [...Cases: [Case: any, Result: any][], DefaultResult: any]
  >,
  Type extends EqualTag | ExtendsTag = ExtendsTag
> = A extends ArrayAndReadonlyArrayByPassArray<
  // 拿到所有 case 语句
  [...infer CaseExpressions, infer DefaultResult]
>
  ? If<
      // 判断 CaseExpressions 集合是否为元组
      IsTuple<CaseExpressions>,
      // 如果 CaseExpressions 集合为元组则依次取出当前 case 判断条件以及该判断条件对应的值
      CaseExpressions extends [
        [infer CurrentCase, infer CurrentResult],
        ...infer OtherCases
      ]
        ? IfExtends<
            // 判断用户传入的 Tag 选项
            [Type, EqualTag],
            If<
              IsEquals<T, CurrentCase>,
              // 如果匹配上,直接返回当前的 Result
              CurrentResult,
              // 如果没有匹配上,递归匹配
              OtherCases extends [Case: any, Result: any][]
                ? Switch<T, [...OtherCases, DefaultResult]>
                : DefaultResult
            >,
            If<
              IsExtends<T, CurrentCase>,
              CurrentResult,
              OtherCases extends [Case: any, Result: any][]
                ? Switch<T, [...OtherCases, DefaultResult]>
                : DefaultResult
            >
          >
        // 如果不满足判断条件,返回默认值
        : DefaultResult,
      // 如果 CaseExpressions 集合不为元组,那么就为空数组或者类型为 [infer Case, infer Result][] 的数组
      If<
        // 先判断是否是空数组
        IsEmptyTypeArray<CaseExpressions>,
        // 空数组返回默认值
        DefaultResult,
        // 如果不是空数组,检验是否符合判断条件语法
        CaseExpressions extends [infer Case, infer Result][]
          // 判断传入的 Tag,再依次做比较
          ? IfExtends<
              [Type, EqualTag],
              If<IsEquals<T, Case>, Result, DefaultResult>,
              If<IsExtends<T, Case>, Result, DefaultResult>
            >
          : // 如果不符合 [infer Case, infer Result][] 返回默认值
            DefaultResult
      >
    >
  : never

总结

我们从最简单的流程控制语句开始,一步步封装出了一套专用于类型的流程控制语句,它能帮助开发者在类型书写时引入抽象的流程概念,或许在一些特殊情况下也可以帮助你更为轻松地创建类型。

不过,在生产环境中我并不推荐使用他们,因为我们在其中使用了大量的泛型嵌套,很容易使得生产环境的代码达到递归极限,消耗大量性能。

除此之外,这里还有一些你应该了解的注意事项:

  • 因为ts的泛型实例化深度限制(大概只有 50 层的实例化深度),在可能涉及大量递归操作时尽量减少使用泛型(typescript 4.5 版本里已经支持了类型运算的尾递归优化,用尾递归的方式来写递归极限可以达到 1000 层,远超原来的 50 层,但还是需要注意这个问题)。
  • 当需要在控制流程泛型中使用递归时,请确保在使用递归语句之前手动用extends关键字书写递归结束条件,否则ts无法识别出递归的终止条件,会报循环引用的错误。

最后,如果你还想更深入学习类型体操,可以在 type-challenges 上尝试,你可以试着使用本文的控制流程语句来解题,或许能够让你有另一种更加清晰的做题思路。