likes
comments
collection
share

「TS类型体操」用递归来做操!

作者站长头像
站长
· 阅读数 55

TypeScript中,当我们需要处理数量不固定的类型时,也就是类型参数的数量不固定、类型参数的嵌套层数不固定等场景下,需要怎么完成类型体操呢?

这就需要通过递归去实现了,其实某种程度上可以看作是通过递归模拟了循环的效果,以下面这个提取Promise最深层的嵌套类型为例,先感受下在类型体操中用递归是什么样的

提取Promise最深层的嵌套类型

提取一层嵌套的Promise类型

在业务场景中,Promise中又套了一层Promise的场景还是比较常见的,如果只是套了一层,或许你可以写出下面这样的类型体操

type NestedPromise = Promise<Promise<string>>

type GetNestedPromiseValueType<P extends Promise<Promise<unknown>>> =
  P extends Promise<Promise<infer ValueType>>
    ? ValueType
    : never

type Res = GetNestedPromiseValueType<NestedPromise> // string

核心的地方在于:

  1. 通过在泛型参数中使用extends约束泛型参数P为一层嵌套的Promise
  2. 通过条件类型结合infer暂时存储一个推导出来的类型ValueType,在条件符合的时候返回ValueType,不匹配时返回never

这样就可以实现一个提取一层嵌套的Promise的泛型参数类型

但是它的使用范围太局限了,只能用于提取一层嵌套的Promise,而对于没有嵌套,或者嵌套层数更多的Promise,就无能为力了

GetNestedPromiseValueType<Promise<string>> // never
GetNestedPromiseValueType<Promise<Promise<Promise<string>>>> // Promise<string>

那这肯定时不行的呀,我们的目的是写出一个可以提取任意嵌套层数的Promise的最终返回值类型,这就要用到递归了

使用递归完成任意嵌套层数的提取

这就涉及到开头说的递归类型体操的应用场景了,也就是类型参数的嵌套层数不确定的场景,那么递归要怎么写呢?首先递归肯定少不了下面这两步:

  1. base case递归跳出条件
  2. 调用自身进行递归

递归跳出的条件就要通过TypeScript的条件类型去实现,而调用自身则是直接调用即可

先看对应的类型体操代码吧

type DeepPromiseValueType<P extends Promise<unknown>> = P extends Promise<
  infer ValueType
>
  ? ValueType extends Promise<unknown>
    ? DeepPromiseValueType<ValueType>
    : ValueType
  : never

type NestedPromise = Promise<Promise<Promise<Record<string, any>>>>

/**
 * {
 *   [x: string]: any
 * }
 */
type Res = DeepPromiseValueType<NestedPromise>
type Res1 = DeepPromiseValueType<Promise<string>> // string
type Res2 = DeepPromiseValueType<Promise<Promise<number>>> // number
type Res3 = DeepPromiseValueType<Promise<Promise<Promise<boolean>>>> // boolean

首先仍然是对泛型参数P进行约束,约束为Promise<unknown>类型,要求至少是一个Promise

然后通过条件类型,使用infer暂存Promise的泛型参数类型,然后再通过一个条件类型,判断这个泛型参数是否也是Promise,是的话就递归调用自身,提取更深一层的类型,不是的话说明已经到达了最深层,此时就返回ValueType即可

这已经包括了递归的两个基本特征了,base case就是ValueType是否依然是Promise,不是则会跳出递归,是的话则会递归调用,这不难理解

最后可以看到,无论多少层都是可以正确提取的

进一步简化

其实还可以进一步简化,第一个条件类型中其实已经帮我们做了对泛型参数进行约束的功能了,所以泛型参数中可以将类型约束的部分删掉

type DeepPromiseValueType<P> = P extends Promise<infer ValueType>
  ? DeepPromiseValueType<ValueType>
  : P

是不是更加优雅了呢~

趁热打铁,我们继续看看还有哪些应用场景

递归数组类型

反转数组

如果要你实现一个类型,能够将一个数组中的元素反转,该怎么做呢?比如下面这个例子

ReverseArr<[1, 2, 3, 4, 5]> // [5, 4, 3, 2, 1]

先动手尝试一下再往下看哦

这其实也对应了我们开头说的递归类型体操的应用场景,这里的场景是泛型参数的元素长度不确定,但我们又需要用到每一个元素

如果不使用递归类型体操,那么一个简单粗暴的写法可能是这样:

type ReverseArr1<Arr extends unknown[]> = Arr extends [
  infer First,
  infer Second,
  infer Third,
  infer Fourth,
  infer Fifth,
]
  ? [Fifth, Fourth, Third, Second, First]
  : Arr

ReverseArr1<[1, 2, 3, 4, 5]> // [5, 4, 3, 2, 1]

十分暴力,虽然确实能够实现我们的需求,但也仅仅是针对这个例子的场景实现的,稍微换一个例子,就不行了

ReverseArr1<[1, 2, 3, 4, 5, 6]> // [1, 2, 3 ,4, 5, 6]

这时候又需要我们的递归兄弟来帮忙了,递归类型体操伺候!

type ReverseArr<Arr extends unknown[]> =
  Arr extends [...infer Rest, infer Last]
    ? [Last, ...ReverseArr<Rest>]
    : Arr

我们依然是用到了条件类型作为base case的区分点,并且用infer提取数组的最后一个元素的类型,当符合条件时,就将最后一个元素放到开头,并将剩余元素传入ReverseArr进行递归调用,这样实现的类型体操,能够将任意长度的数组反转

判断数组中是否存在某个元素

这个场景中,我们需要实现一个类型 -- Includes,它能够接受两个泛型参数,一个是数组,一个是要查找的目标元素,如果元素存在数组中,则返回true,否则返回false

这其实又是一个数组长度不确定,但我们要用到数组中每个元素的情况,这时候就又需要请我们的递归兄弟帮忙了

type Includes<Arr extends unknown[], El> =
  Arr extends [infer FirstEl, ...infer Rest]
    ? FirstEl === El
      ? true
      : Includes<Rest, El>
    : false

同样的套路,通过infer暂存要用到的类型,然后判断提取的元素FirstEl和目标元素El是否相同来返回结果

但这里有一个问题,TS的类型编程中是不支持===这样的运算符的,那么这里判断FirstEl === El的逻辑要怎么修改呢?

实际上可以将===extends泛型约束来替代,只要FirstEl extends El并且El extends FirstEl即可,为了提高代码可读性,更加语义化,我们将这部分逻辑抽离到一个单独的类型Equal中处理

type Equal<A, B> = (A extends B ? true : false) & (B extends A ? true : false)

这样一来,最终的Includes如下:

type Includes<Arr extends unknown[], El> = Arr extends [
  infer FirstEl,
  ...infer Rest,
]
  ? Equal<FirstEl, El> extends true
    ? true
    : Includes<Rest, El>
  : false

type Res = Includes<[1, 2, 3, 4, 5], 3> // true
type Res1 = Includes<[1, 2, 3, 4, 5], 6> // false

递归字符串类型

对于字符串类型,也可以使用递归类型体操,还是通过具体场景来感受一下

ReplaceAll

这个类型的功能就和它的名字一样,就是用于接受一个字符串泛型参数Str,一个待替换字符串泛型参数Src和一个目标字符串泛型参数Target

能够把Str中的所有Src替换成Target即可,由于Str中有多少个Src我们是不知道的,它是一个不确定的数量,这时候又对应到开头说的递归类型体操的使用场景了

type ReplaceAll<
  Str extends string,
  Src extends string,
  Target extends string,
> = Str extends `${infer Prefix}${Src}${infer Suffix}`
  ? ReplaceAll<`${Prefix}${Target}${Suffix}`, Src, Target>
  : Str

// "I love TypeScript     ever!"
type Res = ReplaceAll<'I love TypeScript for for for for forever!', 'for', ''>

字符串拆分成联合类型

考虑下面这个场景,给你一个字符串TypeScript,经过类型体操的训练后,得到一个"T" | "y" | "p" | "e" | "S" | "c" | "r" | "i" | "t"的类型

如果说字符串长度是固定的话,那么我们可以在条件类型中通过infer去暂存各个位置的字符,然后在结果中将它们用联合运算符|拼接起来即可

但字符串肯定不是固定某个长度的,它的长度是未知的,这时候就又需要咱们的递归类型体操发挥作用了

type StringToUnion<Str extends string> =
  Str extends `${infer Char}${infer Rest}` ? Char | StringToUnion<Rest> : never

// "T" | "y" | "p" | "e" | "S" | "c" | "r" | "i" | "t"
type Res = StringToUnion<'TypeScript'>

反转字符串

和之前反转数组类似,但是有一点需要注意,之前反转数组的时候,因为可以通过...标识哪一边是单一元素,哪一边是剩余元素,由于字符串不具有这个特性,所以我们不能像之前反转数组一样提取最后一个元素放到前面,而是要改为提取第一个元素

// 数组可以这样提取最后一个元素
Arr extends [...infer Rest, infer Last]

// 但是字符串不行
Str extends `${infer Rest}${infer Last}`
// 以 'TypeScript' 这个字符串为例,得到的 Rest 是 "T"
// 而 Last 是 "ypeScript" 和语义不符

所以我们的写法如下:

// 没使用 `${Rest}${Last}`
// 是因为如果这样写 那么 Rest 就是第一个字符
// 而 Last 是后续的字符串 不符合语义
type ReverseStr<
  Str extends string,
  Result extends string = '',
> = Str extends `${infer First}${infer Rest}`
  ? ReverseStr<Rest, `${First}${Result}`>
  : Result

type Res = ReverseStr<'TypeScript'> // "tpircSepyT"

递归索引类型

TypeScript内置的Readonly实现

索引类型其实也就是我们在js中遇到的对象,对于索引类型,ts有一个内置的类型 -- Readonly,可以将索引类型的所有索引都转成readonly,也就是将对象的所有属性都用readonly只读修饰符去修饰

第一版实现如下:

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

使用的结果如下:

interface Foo {
  name: string
  age: number
  friends: string[]
  bar: {
    name: string
    age: number
  }
}

/**
 * {
 *   readonly name: string
 *   readonly age: number
 *   readonly friends: string[]
 *   readonly bar: {
 *     name: string
 *     age: number
 *   }
 * }
 */
type Res = MyReadonly<Foo>

可以看到,只能将最外层的属性变成readonly,而嵌套的属性并没有变化,由于嵌套的层数我们并不能确定,所以又可以使用递归类型体操去处理,实现一个DeepReadonly

第一版DeepReadonly实现

一个简单的实现是下面这样,遇到object就递归调用

type DeepReadonly<T> = {
  readonly [P in keyof T]: T[P] extends object ? DeepReadonly<T[P]> : T[P]
}

interface Foo {
  name: string
  age: number
  friends: string[]
  hi: () => void
  bar: {
    name: string
    age: number
    hi: () => void
  }
}

// type Res = {
//   readonly name: string
//   readonly age: number
//   readonly friends: readonly string[]
//   readonly hi: DeepReadonly<() => void>
//   readonly bar: DeepReadonly<{
//     name: string
//     age: number
//     hi: () => void
//   }>
// }
type Res = DeepReadonly<Foo>

可以看到,有两个问题:

  1. 函数类型不应该再递归处理成readonly
  2. 并没有直接计算出类型的结果,比如bar对象中的属性并没有被DeepReadonly进行计算

第二版DeepReadonly实现 -- 解决函数类型问题

首先我们来解决第一个问题,这是因为ts会把Function extends object视为true

Function extends object ? true : false // true

不过这也合理,毕竟在js中函数也是一个对象,那么我们就需要添加一下对Function的约束

type DeepReadonly<T> = {
  readonly [P in keyof T]: T[P] extends object
    ? T[P] extends Function
      ? T[P]
      : DeepReadonly<T[P]>
    : T[P]
}

这样一来第一个问题就解决了:

// type Res = {
//   readonly name: string
//   readonly age: number
//   readonly friends: readonly string[]
//   readonly hi: () => void
//   readonly bar: DeepReadonly<{
//     name: string
//     age: number
//     hi: () => void
//   }>
// }
type Res = DeepReadonly<Foo>

第三版DeepReadonly实现 -- 解决懒计算问题

ts只有在用到对应属性的时候才会去计算

// type Res = {
//   readonly name: string
//   readonly age: number
//   readonly hi: () => void
// }
type Res = DeepReadonly<Foo['bar']>

可以加上一段T extends any来强行进行类型计算,

type DeepReadonly<T> = T extends any
  ? {
      readonly [P in keyof T]: T[P] extends object
        ? T[P] extends Function
          ? T[P]
          : DeepReadonly<T[P]>
        : T[P]
    }
  : never

现在的结果如下:

// type Res = {
//   readonly name: string
//   readonly age: number
//   readonly friends: readonly string[]
//   readonly hi: () => void
//   readonly bar: {
//     readonly name: string
//     readonly age: number
//     readonly hi: () => void
//   }
// }
type Res = DeepReadonly<Foo>

这样就相对来说更完美一些

转载自:https://juejin.cn/post/7127164642396209188
评论
请登录