07-套路三:递归复用做循环
课程
1
如何阅读本小册
已学完
学习时长: 2分6秒
2
为什么说 TypeScript 的火爆是必然?
已学完
学习时长: 4分28秒
3
TypeScript 类型编程为什么被叫做类型体操?
已学完
学习时长: 9分16秒
4
TypeScript 类型系统支持哪些类型和类型运算?
已学完
学习时长: 19分27秒
5
套路一:模式匹配做提取
已学完
学习时长: 41分32秒
6
套路二:重新构造做变换
已学完
学习时长: 39分51秒
7
套路三:递归复用做循环
学习时长: 42分22秒
8
套路四:数组长度做计数
学习时长: 27分58秒
9
套路五:联合分散可简化
学习时长: 19分36秒
10
套路六:特殊特性要记清
学习时长: 36分41秒
11
类型体操顺口溜
学习时长: 27分26秒
12
TypeScript 内置的高级类型有哪些?
学习时长: 31分59秒
13
真实案例说明类型编程的意义
学习时长: 34分36秒
14
类型编程综合实战一
学习时长: 25分55秒
15
类型编程综合实战二
学习时长: 29分9秒
16
新语法 infer extends 是如何简化类型编程的
学习时长: 11分14秒
17
原理篇:逆变、协变、双向协变、不变
学习时长: 11分31秒
18
原理篇:编译 ts 代码用 tsc 还是 babel?
学习时长: 19分49秒
19
原理篇:实现简易 TypeScript 类型检查
学习时长: 47分51秒
20
原理篇:如何阅读 TypeScript 源码
学习时长: 19分27秒
21
原理篇:一些特殊情况的说明
已学完
学习时长: 12分39秒
22
小册总结
学习时长: 2分32秒
23
加餐:3 种类型来源和 3 种模块语法
学习时长: 17分6秒
24
加餐:用 Project Reference 优化 tsc 编译性能
已学完
学习时长: 4分47秒
juejin_logo copyCreated with Sketch.

会做类型的提取和构造之后,我们已经能写出很多类型编程逻辑了,但是有时候提取或构造的数组元素个数不确定、字符串长度不确定、对象层数不确定。这时候怎么办呢?

其实前面的案例我们已经涉及到了一些,就是递归。

这就是第三个类型体操套路:递归复用做循环。

递归复用

递归是把问题分解为一系列相似的小问题,通过函数不断调用自身来解决这一个个小问题,直到满足结束条件,就完成了问题的求解。

TypeScript 的高级类型支持类型参数,可以做各种类型运算逻辑,返回新的类型,和函数调用是对应的,自然也支持递归。

TypeScript 类型系统不支持循环,但支持递归。当处理数量(个数、长度、层数)不固定的类型的时候,可以只处理一个类型,然后递归的调用自身处理下一个类型,直到结束条件也就是所有的类型都处理完了,就完成了不确定数量的类型编程,达到循环的效果。

既然提到了数组、字符串、对象等类型,那么我们就来看一下这些类型的递归案例吧。

Promise 的递归复用

DeepPromiseValueType

先用 Promise 热热身,实现一个提取不确定层数的 Promise 中的 value 类型的高级类型。

type ttt = Promise<Promise<Promise<Record<string, any>>>>;

这里是 3 层 Promise,value 类型是索引类型。

数量不确定,一涉及到这个就要想到用递归来做,每次只处理一层的提取,然后剩下的到下次递归做,直到结束条件。

所以高级类型是这样的:

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

类型参数 P 是待处理的 Promise,通过 extends 约束为 Promise 类型,value 类型不确定,设为 unknown。

每次只处理一个类型的提取,也就是通过模式匹配提取出 value 的类型到 infer 声明的局部变量 ValueType 中。

然后判断如果 ValueType 依然是 Promise类型,就递归处理。

结束条件就是 ValueType 不为 Promise 类型,那就处理完了所有的层数,返回这时的 ValueType。

这样,我们就提取到了最里层的 Promise 的 value 类型,也就是索引类型:

试一下

其实这个类型的实现可以进一步的简化:

type DeepPromiseValueType2<T> = 
    T extends Promise<infer ValueType> 
        ? DeepPromiseValueType2<ValueType>
        : T;

不再约束类型参数必须是 Promise,这样就可以少一层判断。

试一下

接下来再看下数组类型的递归复用:

数组类型的递归

ReverseArr

有这样一个元组类型:

type arr = [1,2,3,4,5];

我们把它反过来,也就是变成:

type arr = [5,4,3,2,1];

这个学完了提取和构造很容易写出来:

type ReverseArr<Arr extends unknown[]> = 
    Arr extends [infer One, infer Two, infer Three, infer Four, infer Five]
        ? [Five, Four, Three, Two, One]
        : never;

但如果数组长度不确定呢?

数量不确定,条件反射的就要想到递归。

我们每次只处理一个类型,剩下的递归做,直到满足结束条件。

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

类型参数 Arr 为待处理的数组类型,元素类型不确定,也就是 unknown。

每次只处理一个元素的提取,放到 infer 声明的局部变量 First 里,剩下的放到 Rest 里。

用 First 作为最后一个元素构造新数组,其余元素递归的取。

结束条件就是取完所有的元素,也就是不再满足模式匹配的条件,这时候就返回 Arr。

试一下

Includes

既然递归可以做循环用,那么像查找元素这种自然也就可以实现。

比如查找 [1, 2, 3, 4, 5] 中是否存在 4,是就返回 true,否则返回 false。

从长度不固定的数组中查找某个元素,数量不确定,这时候就应该想到递归。

type Includes<Arr extends unknown[], FindItem> = 
    Arr extends [infer First, ...infer Rest]
        ? IsEqual<First, FindItem> extends true
            ? true
            : Includes<Rest, FindItem>
        : false;

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

类型参数 Arr 是待查找的数组类型,元素类型任意,也就是 unknown。FindItem 待查找的元素类型。

每次提取一个元素到 infer 声明的局部变量 First 中,剩余的放到局部变量 Rest。

判断 First 是否是要查找的元素,也就是和 FindItem 相等,是的话就返回 true,否则继续递归判断下一个元素。

直到结束条件也就是提取不出下一个元素,这时返回 false。

相等的判断就是 A 是 B 的子类型并且 B 也是 A 的子类型,。

这样就完成了不确定长度的数组中的元素查找,用递归实现了循环。

当包含时:

当不包含时:

试一下

RemoveItem

可以查找自然就可以删除,只需要改下返回结果,构造一个新的数组返回。

type RemoveItem<
    Arr extends unknown[], 
    Item, 
    Result extends unknown[] = []
> = Arr extends [infer First, ...infer Rest]
        ? IsEqual<First, Item> extends true
            ? RemoveItem<Rest, Item, Result>
            : RemoveItem<Rest, Item, [...Result, First]>
        : Result;
        
type IsEqual<A, B> = (A extends B ? true : false) & (B extends A ? true : false);

类型参数 Arr 是待处理的数组,元素类型任意,也就是 unknown[]。类型参数 Item 为待查找的元素类型。类型参数 Result 是构造出的新数组,默认值是 []。

通过模式匹配提取数组中的一个元素的类型,如果是 Item 类型的话就删除,也就是不放入构造的新数组,直接返回之前的 Result。

否则放入构造的新数组,也就是再构造一个新的数组 [...Result, First]。

直到模式匹配不再满足,也就是处理完了所有的元素,返回这时候的 Result。

这样我们就完成了不确定元素个数的数组的某个元素的删除:

试一下

BuildArray

我们学过数组类型的构造,如果构造的数组类型元素个数不确定,也需要递归。

比如传入 5 和元素类型,构造一个长度为 5 的该元素类型构成的数组。

type BuildArray<
    Length extends number, 
    Ele = unknown, 
    Arr extends unknown[] = []
> = Arr['length'] extends Length 
        ? Arr 
        : BuildArray<Length, Ele, [...Arr, Ele]>;

类型参数 Length 为数组长度,约束为 number。类型参数 Ele 为元素类型,默认值为 unknown。类型参数 Arr 为构造出的数组,默认值是 []。

每次判断下 Arr 的长度是否到了 Length,是的话就返回 Arr,否则在 Arr 上加一个元素,然后递归构造。

试一下

学完了数组类型的递归,我们再来看下字符串类型。

字符串类型的递归

ReplaceAll

学模式匹配的时候,我们实现过一个 Replace 的高级类型:

type ReplaceStr<
    Str extends string,
    From extends string,
    To extends string
> = Str extends `${infer Prefix}${From}${infer Suffix}` 
    ? `${Prefix}${To}${Suffix}` : Str;

它能把一个字符串中的某个字符替换成另一个:

但是如果有多个这样的字符就处理不了了。

如果不确定有多少个 From 字符,怎么处理呢?

在类型体操里,遇到数量不确定的问题,就要条件反射的想到递归。

每次递归只处理一个类型,这部分我们已经实现了,那么加上递归的调用就可以。

type ReplaceAll<
    Str extends string, 
    From extends string, 
    To extends string
> = Str extends `${infer Left}${From}${infer Right}`
        ? `${Left}${To}${ReplaceAll<Right, From, To>}`
        : Str;

类型参数 Str 是待处理的字符串类型,From 是待替换的字符,To 是替换到的字符。

通过模式匹配提取 From 左右的字符串到 infer 声明的局部变量 Left 和 Right 里。

用 Left 和 To 构造新的字符串,剩余的 Right 部分继续递归的替换。

结束条件是不再满足模式匹配,也就是没有要替换的元素,这时就直接返回字符串 Str。

这样就实现了任意数量的字符串替换:

试一下

StringToUnion

我们想把字符串字面量类型的每个字符都提取出来组成联合类型,也就是把 'dong' 转为 'd' | 'o' | 'n' | 'g'。

怎么做呢?

很明显也是提取和构造:

type StringToUnion<Str extends string> = 
    Str extends `${infer One}${infer Two}${infer Three}${infer Four}`
        ? One | Two | Three | Four
        : never;

但如果字符串长度不确定呢?

数量不确定,在类型体操中就要条件反射的想到递归。

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

类型参数 Str 为待处理的字符串类型,通过 extends 约束为 string。

通过模式匹配提取第一个字符到 infer 声明的局部变量 First,其余的字符放到局部变量 Rest。

用 First 构造联合类型,剩余的元素递归的取。

这样就完成了不确定长度的字符串的提取和联合类型的构造:

试一下

ReverseStr

我们实现了数组的反转,自然也可以实现字符串类型的反转。

同样是递归提取和构造。

type ReverseStr<
    Str extends string, 
    Result extends string = ''
> = Str extends `${infer First}${infer Rest}` 
    ? ReverseStr<Rest, `${First}${Result}`> 
    : Result;

类型参数 Str 为待处理的字符串。类型参数 Result 为构造出的字符,默认值是空串。

通过模式匹配提取第一个字符到 infer 声明的局部变量 First,其余字符放到 Rest。

用 First 和之前的 Result 构造成新的字符串,把 First 放到前面,因为递归是从左到右处理,那么不断往前插就是把右边的放到了左边,完成了反转的效果。

直到模式匹配不满足,就处理完了所有的字符。

这样就完成了字符串的反转:

试一下

学完了字符串的递归,我们再来看下对象的。

对象类型的递归

DeepReadonly

对象类型的递归,也可以叫做索引类型的递归。

我们之前实现了索引类型的映射,给索引加上了 readonly 的修饰:

type ToReadonly<T> =  {
    readonly [Key in keyof T]: T[Key];
}

如果这个索引类型层数不确定呢?

比如这样:

type obj = {
    a: {
        b: {
            c: {
                f: () => 'dong',
                d: {
                    e: {
                        guang: string
                    }
                }
            }
        }
    }
}

数量(层数)不确定,类型体操中应该自然的想到递归。

我们在之前的映射上加入递归的逻辑:

type DeepReadonly<Obj extends Record<string, any>> = {
    readonly [Key in keyof Obj]:
        Obj[Key] extends object
            ? Obj[Key] extends Function
                ? Obj[Key] 
                : DeepReadonly<Obj[Key]>
            : Obj[Key]
}

类型参数 Obj 是待处理的索引类型,约束为 Record<string, any>,也就是索引为 string,值为任意类型的索引类型。

索引映射自之前的索引,也就是 Key in keyof Obj,只不过加上了 readonly 的修饰。

值要做下判断,如果是 object 类型并且还是 Function,那么就直接取之前的值 Obj[Key]。

如果是 object 类型但不是 Function,那就是说也是一个索引类型,就递归处理 DeepReadonly<Obj[Key]>。

否则,值不是 object 就直接返回之前的值 Obj[Key]。

这样就完成了任意层数的索引类型的添加 readonly 修饰:

我们取处理以后的索引 a 的值看一下,发现 b 已经加上了 readonly 修饰。

测试一下:

试一下

为啥这里没有计算呀?

因为 ts 只有类型被用到的时候才会做类型计算。

所以可以在前面加上一段 Obj extends never ? never 或者 Obj extends any 等,让它触发计算:

type DeepReadonly<Obj extends Record<string, any>> =
    Obj extends any
        ? {
            readonly [Key in keyof Obj]:
                Obj[Key] extends object
                    ? Obj[Key] extends Function
                        ? Obj[Key] 
                        : DeepReadonly<Obj[Key]>
                    : Obj[Key]
        }
        : never;

这样就显示了计算后的类型:

而且写 Obj extends any 还有额外的好处就是能处理联合类型,这个可以看套路五,会有解释。

试一下

总结

递归是把问题分解成一个个子问题,通过解决一个个子问题来解决整个问题。形式是不断的调用函数自身,直到满足结束条件。

在 TypeScript 类型系统中的高级类型也同样支持递归,在类型体操中,遇到数量不确定的问题,要条件反射的想到递归。 比如数组长度不确定、字符串长度不确定、索引类型层数不确定等。

如果说学完了提取和构造可以做一些基础的类型体操,那再加上递归就可以实现各种复杂类型体操了。

本文案例的合并

(其实这节的 IsEqual 判断是不完善的,套路六里面会讲原因)

留言
Ctrl + Enter
全部评论(46)
慢功夫的头像
删除
前端工程师
字符串反转可以采用转联合类型相同写法,无需再引入一个中间变量

type ReverseStr<Str extends string> =
Str extends `${infer First}${infer Rest}`
? `${ReverseStr<Rest>}${First}`
: Str;
type ReverseStrResult = ReverseStr<'hello'>;
展开
1
回复
感觉时刻的头像
删除
学完ts递归复用打卡
1
回复
hedgehog_boy的头像
删除
跟示例得到的结果不一样,是版本问题吗,我把示例代码直接粘贴也是这个结果,还是说顺序其实是不重要的
1
回复
hedgehog_boy的头像
删除
继续学习打卡,套路三还是得需要理解和练习才能更加熟练
1
回复
Doerme的头像
删除
FE @ 百度/YY
guang dong 是暗示 广东 吗😄
点赞
2
删除
(作者)
[哭笑]
点赞
回复
删除
可能是光 咚咚咚在敲门[呲牙]
点赞
回复
三郎mr的头像
删除
光神讲的太好了,深入浅出,易懂。
这里有一个现象不太了解,请求有没有相关这个现象的解释资料文档,我去看看:
const fn1 = (t) => t;
const fn2 = (t) => t;
const fn3 = (t) => t;
const fn4 = (t) => t;
var cc = fn1(fn2(fn3(fn4())));
上述js执行时,先执行 fn4() ,然后依次执行 fn3()、fn2()、fn1();
可以看出,js执行时,先执行fn4,最后才是执行fn1;

文中的第一个例子:
type DeepPromiseValueType<P extends Promise<unknown>> =
P extends Promise<infer ValueType>
? ValueType extends Promise<unknown>
? DeepPromiseValueType<ValueType>
: ValueType
: never;

type DeepPromiseResult = DeepPromiseValueType<Promise<Promise<Record<string, any>>>>;
上面这个例子看出,在ts的类型运算中,貌似不像js一样的执行顺序,
如果把DeepPromiseValueType比作一个js的函数的话,
它却没有先执行DeepPromiseValueType函数的入参(Promise<Promise<Record<string, any>>>),
而是先执行 DeepPromiseValueType 的运算逻辑,然后再利用其递归逻辑,
一层层获取最终的 Record<string, any> ;

请问有以上现象解释的相关资料吗
展开
点赞
1
删除
很简单,js执行顺序是按照函数的执行逻辑。而TS从外到内是按照”模式匹配“的逻辑
点赞
回复
Awesome_白嫖党的头像
删除
资深BAT前端劝退师 @ 前端劝退有限公司
“因为 ts 只有类型被用到的时候才会做类型计算” 这里我还是不太明白,难道 "readonly [Key in keyof Obj]" 进行索引查询的时候不是在使用吗,还是说 “Obj” 使用 “extends” 进行计算时候才算是使用到?
点赞
2
删除
(作者)
说的是整个类型被用到的时候才会执行其中的计算逻辑
1
回复
删除
学到了
说的是整个类型被用到的时候才会执行其中的计算逻辑
点赞
回复
codermao的头像
删除
web实习 @ 小红书
对于这种情况要如何解决啊光哥
点赞
2
删除
(作者)
因为结束条件一直不满足呀,所以无限递归了
点赞
回复
删除
这里就会遇到传参无法约束的问题,借用群友讨论的方案,可以另外重新定义一下`BuildArray`,如`type BuildArrayImpl = balabala...`,实际方法`type BuildArray<Length extends number, Element extends unknown> = BuildArrayImpl<Length, Element, []>`
点赞
回复
codermao的头像
删除
web实习 @ 小红书
有意思,喔觉得反转数组可以这样。
```ts
type ReverseArr<Arr extends unknown[]> = Arr extends [
infer First,
...infer Center,
infer End
]
? [End, ...ReverseArr<Center>, First]
: Arr;

```
展开
3
1
删除
可以的
点赞
回复
很帅很愁人的头像
删除
全村工程师 @ 下王庄村委员会
好难啊,
1
1
删除
(作者)
多在 playground 试一下
点赞
回复
CoderStan的头像
删除
前端开发
type IsEqual<T, U> = (T extends U ? true : false) & (U extends T ? true : false)
type a = IsEqual<boolean, false>
type b = (boolean extends false ? true : false) & (false extends boolean ? true : false)
你好,可以解释以下这个问题吗,这里 type a 是 true,而 type b 是 never,想问一下为什么两者的结果不一致[疑问]
点赞
2
删除
(作者)
boolean 是联合类型,是 true 和 false 的联合,这个我在原理篇会讲,我今天更一下
点赞
回复
删除
好的,感谢
boolean 是联合类型,是 true 和 false 的联合,这个我在原理篇会讲,我今天更一下
点赞
回复
bubbletg的头像
删除
都是大佬,大佬
点赞
回复
BokFang的头像
删除
前端 @ CVTE_希沃
对象类型的递归,这么写是不是简介一些:
type DeepReadonly<T extends object> = {
readonly [key in keyof T]:
T[key] extends Record<string | number | symbol, unknown>
? DeepReadonly<T[key]>
: T[key]
}
展开
2
1
删除
(作者)
可以的,写法不唯一
点赞
回复
egema的头像
删除
将代码type RemoveItem<...放入自己的IDE里,会报
TS2456: Type alias 'RemoveItem' circularly references itself.

TS2574: A rest element type must be an array type.
TS2315: Type 'RemoveItem' is not generic.
这3个错误,ts版本是4.6.2,tsconfig.json配置为空。请问下光神这该如何解决[红脸]
点赞
1
删除
(作者)
额,看起来确实是 ts 版本问题,应该是你 vscode 内置的 ts 版本太低了
点赞
回复
用户9484004390385的头像
删除
// 数组移除,这样写应该也可以。试了一下。type RemoveItem<Arr extends unknown[], Item> = Arr extends [infer First, ...infer Rest]
? IsEqual<First, Item> extends true
? RemoveItem<Rest, Item>
: [First, ...RemoveItem<Rest, Item>]
: Arr
3
1
删除
(作者)
[赞],写法不唯一
点赞
回复
爱吃鱼的桶哥Z的头像
删除
伪 · 全栈打杂攻城狮
学习打卡
点赞
回复
lxb不想说话的头像
删除
数组的Includes 这样写更简洁
type Includes2<T extends any[], U> = U extends T[number] ? true : false
点赞
4
删除
(作者)
👍,可以的,也是一种思路,这里主要还是讲递归的思路
1
回复
删除
简单的可以,但需要考虑这些情况
2
回复
查看更多回复
hehe1111的头像
删除
请教一下,最后的对象类型的递归的实现,下面的这种实现,返回的结果为什么会跟小册的结果不同?为什么小册的解法,在取深层属性的类型时,前面会有一个 DeepReadonly?
```js
type DeepReadonly<T extends Object> = T extends Object
? {
readonly [Key in keyof T as Key extends string
? Key
: never]: T[Key] extends Object
? T[Key] extends Function
? T[Key]
: DeepReadonly<T[Key]>
: T[Key]
}
: T
```
展开
点赞
1
删除
(作者)
正常,因为没有用到的时候不会求值,你取属性看下类型就知道了
1
回复
chencs的头像
删除
前端开发工程
type ReverseStr1<
Str extends string,
> = Str extends `${infer one}${infer rest}` ? `${ReverseStr1<rest>}${one}` : Str; 字符串反转,这样写也是ok的吧
4
1
删除
(作者)
可以的,写法不唯一
点赞
回复
webgzh907247189的头像
删除
打杂 @ 打杂
type DeepPromiseValueType<T> = T extends Promise<infer R> ? DeepPromiseValueType<R> : T;
这样判断是不是更简洁?
2
2
删除
(作者)
是的,我写复杂了,不过都可以
2
回复
删除
这样写就不能保证传入的是promise了
点赞
回复

查看全部 46 条回复