cd ../

TS Gymnastics 2 - Build Basic Types

2022.07.7TypeScript

Previously on TS Gymnastics.

Built-in Types

利用基础的 TS 类型操作,可以创造某些常用的类型。这些类型也是 TS 类型系统内置的类型。

Readonly

实现起来很简单,利用映射类型将readonly添加到所有属性上即可。

type MyReadonly<T> = {
  readonly [P in keyof T]: T[P]
}

Exclude

Exclude<T, U>用于从联合类型中T排除指定类型U。从联合类型中排除类型可以通过在extends子句中返回never实现,问题的关键是构建extends语句。

type MyExclude<T, U> = T extends U ? never : T

此处利用了extends分发性质,在上一篇中已有介绍。

Awaited

Awaited<T>用于从Promise<T>中提取类型T,而提取类型正是extendsinfer组合的常见适用场景。

type MyAwaited<T extends Promise<any>> = T extends Promise<infer R> ? R : never

如果考虑到infer R可能是Promise类型,需要递归调用MyAwaited

type MyAwaited<T extends Promise<any>> =
  T extends PromiseLike<infer R>
    ? R extends Promise<any>
      ? MyAwaited<R>
      : R
    : never

ParametersReturnType

Parameters<T>用于从函数类型T中提取参数的类型,而ReturnType<T>用于提取函数T的返回值的类型。这两个类型都是从函数中提取类型,依然同上面的问题一样需要使用extendsinfer的组合。事实上,两者的唯一区别就在于infer推断的位置不同。

type MyParameters<T extends (...args: any[]) => any> =
  T extends (...args: infer R) => any ? R : never

type MyReturnType<T extends (...args: any[]) => any> =
  T extends (...args: any[]) => infer R ? R : never

值得一提的是,类型推断的结果与泛型类型签名有关。观察下面的例子:

const fn1 = (v: boolean) => v ? 1 : 2
const fn2 = (v: boolean): number => v ? 1 : 2

type P1 = MyReturnType<typeof fn1> // Type: 1 | 2
type P2 = MyReturnType<typeof fn2> // Type: number

Omit

Omit<T, K>用于从类型T中排除指定属性K,与Exclude的区别在于Omit是排除T中的属性(键),而Exclude是排除联合类型中的类型。

上一篇中已经介绍过,通过在键上使用never可以排除该属性。

type MyOmit<T, K extends keyof T> = {
  [P in keyof T as P extends K ? never : P]: T[P]
}

常用类型

在复杂的类型体操(本系列不会涉及)中,往往需要封装一组常用的基本类型来实现某些算法。

If

If<C, T, F>用于根据条件C判断是否返回类型T,否则返回类型F

type If<C extends boolean, T, F> = C extends true ? T : F

元组转对象

将给定的元组(例如['a', 'b', 'c'])转换为对象({ a: 'a', b: 'b', c: 'c' })。

首先,需要确定输入的泛型元素为元组,且需要能够作为对象的键。

type TupleToObject<T extends readonly PropertyKey[]> = { /* */ }

接下来,需要构造映射类型:

type TupleToObject<T extends readonly PropertyKey[]> = {
  [K in <T中的每个值>]: K
}

最后的问题在于如何获取元组中的每个值。在上一篇中提及,只需要使用[number]索引访问即可。

type TupleToObject<T extends readonly PropertyKey[]> = {
  [K in T[number]]: K
}

获取元组的长度

在前一篇介绍keyof中提及,如果把数组看成是一个类型,那么keyof可以获取到数组的属性,其中也包括length。只要读取length属性,就能获取元组的长度。

type Length<T extends readonly any[]> = T['length']

获取数组的第一个/最后一个元素

这两个工具类型的实现较为相似。这也涉及到从某个类型中提取一个类型的操作,因此使用infer进行推断是自然而然的。

type First<T extends any[]> = T extends [infer F, ...infer _] ? F : never
type Last<T extends any[]> = T extends [...infer _, infer L] ? L : never

Concat

实现Concat<T, U>类型,用于将两个元组TU拼接起来。利用扩展运算符即可实现拼接。

type Concat<T extends readonly any[], U extends readonly any[]> = [...T, ...U]

PushUnshift

Concat类似,这两个类型都是在数组中增加元素。唯一的区别就是U不是数组,但依然使用扩展运算符实现。

type Push<T extends any[], U> = [...T, U]
type Unshift<T extends any[], U> = [U, ...T]

PopShift

这两个类型都是从数组中移除第一个或最后一个元素,需要先#获取数组的第一个/最后一个元素,然后返回infer推断的剩余部分。

type Pop<T extends any[]> = T extends [...infer R, infer _] ? R : []
type Shift<T extends any[]> = T extends [infer _, ...infer R] ? R : []

Equal

Equal<T, U>用于判断类型TU是否严格相等。这是一个非常经典的反直觉案例。它的核心是利用函数参数的逆变和条件类型构造的「严格类型等价判断」。在此直接给出实现:

type IsEqual<T, U> =
    (<G>() => G extends T ? 1 : 2) extends
    (<G>() => G extends U ? 1 : 2)
        ? true
        : false

错误实现

那么,为什么不能直接使用T extends UU extends T来判断类型是否相等呢?考虑以下情况:

type IncorrectEqual<T, U> = T extends U
  ? U extends T
    ? true
    : false
  : false

// 含有 any
type A = IncorrectEqual<any, 1> // boolean, expected false
// 含有联合类型
type B = IncorrectEqual<1 | 2, 1> // boolean, expected false

在继续之前,需要分析一下为什么上面的例子中AB会有这样的结果。首先看含有联合类型的B。当T = 1 | 2时,因为T是裸类型参数,所以T extends U会触发类型分发。带入分发可得到等价类型:

(1 extends 1 ? 1 extends 1 ? ...) | (2 extends 1 ? 1 extends 2 ? ...)

// 其中
1 extends 1 // true
2 extends 1 // false

// 合并结果
// true | false
// TS 自动合并为 boolean

再看类型A。因为any可能为任何类型,因此会“分裂”为两个分支分别进行判断。分支 1 中,1 extends anytrue,分支 2 直接返回false。因此,同上进行合并后,A的类型为boolean

正确实现和原因

那为何使用函数进行比较就能得到正确的结果呢?IsEqual的核心是判断<G>() => G extends T ? 1 : 2是否为<G>() => G extends U ? 1 : 2的子类型。这是含有泛型参数的函数,且函数无参,按照逆变与协变中的介绍,若要使<G>() => G extends T ? 1 : 2<G>() => G extends U ? 1 : 2的子类型,则需要对于任意类型 G,都有(G extends T ? 1 : 2) extends (G extends U ? 1 : 2)

不妨进行倒推。T 和 U 要么相等,要么不相等。假设T ≠ U,那么必定存在某个 G,使得G extends T ≠ G extends U。可以轻松证明。假设T = U,那么对于任意 G 都应当有G extends T = G extends U。显然,这种情况下等式成立。

综上,判断两个类型是否相等的Equal的实现,是建立在泛型函数的全称量化特性之上的。普通的T extends U是在判断 T 是否是 U 的子集,而泛型函数的实现版本则是在判断 T 和 U 对于所有可能得输入 G,行为是否一致。

Includes

有了IsEqual的实现,就可以在此基础上实现数组的Includes类型。需要用到递归类型,依次比较数组T中的每个类型是否与U相等。

type IsEqual<T, U> = (<G>() => G extends T ? 1 : 2) extends (<G>() => G extends U ? 1 : 2) ? true : false

type Includes<T extends readonly any[], U> = T extends [infer F, ...infer R]
  ? IsEqual<F, U> extends true
    ? true
    : Includes<R, U>
  : false

递归展开数组

Flatten用于递归展开数组,将嵌套的数组转换为平铺的数组。例如Flatten<[1, [2, 3], 4, [5, 6]]]>[1, 2, 3, 4, 5, 6]

实现方式和Includes类似,都是依次观察数组中每个元素是否是嵌套的,并递归地应用Flatten类型。

type Flatten<T extends any[]> = T extends []
  ? []
  : T extends [infer F, ...infer R]
    ? F extends any[]
      ? [...Flatten<F>, ...Flatten<R>]
      : [F, ...Flatten<R>]
    : never

需要注意的是,由于Flatten<[]>的结果应当为[],且[] extends [infer F, ...infer R]的表达式为false,所以需要在递归中添加一个判断。

递归展开数组 N 次

Flatten的实现中,总是递归地展开数组。现要实现类型FlattenDepth<T, N>,使得数组 T 被递归展开 N 层。即:

type A = FlattenDepth<[1, 2, [3, 4], [[[5]]]], 2> // [1, 2, 3, 4, [5]]. 展开 2 层
type B = FlattenDepth<[1, 2, [3, 4], [[[5]]]]> // [1, 2, 3, 4, [[5]]]. 展开默认 1 层

问题的关键在于如何实现递归次数的累计。显然,N 作为number类型,无法像 JS 层面的变量那样直接进行增减操作。但是,在类型系统中,可以操作数组,通过向数组中增加元素的方式来实现递归次数的累计。只需要检查数组的长度是否等于 N 即可。

关键在于何时需要向计数数组U中增加元素。自然是需要在递归展开时。为了搞清楚展开发生在哪一步,不妨先观察上面Flatten的实现。如果infer F推断为any[]的子类型,那么需要对F进行展开,因此调用Flatten<F>的时候就是展开的时候。所以,在FlattenDepth的实现中,也应当在展开F时向U中增加元素。下面的代码中,以高亮行标出此处,向U中增加任意元素(此处为 1)。

// 初始化 U 参数,用于记录递归次数
type FlattenDepth<T extends any[], N extends number = 1, U extends any[] = []>
  // 首先检查是否满足了迭代次数要求
  = U['length'] extends N
    ? T
    : T extends [infer F, ...infer R]
      ? F extends any[]
        ? [...FlattenDepth<F, N, [...U, 1]>, ...FlattenDepth<R, N, U>]
        : [F, ...FlattenDepth<R, N, U>]
      : T
Fin.
cd ../