11-类型体操顺口溜
课程
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 的类型也可以通过匹配一个模式类型来提取部分类型到 infer 声明的局部变量中返回。

比如提取函数类型的返回值类型:

type GetReturnType<Func extends Function> = 
    Func extends (...args: any[]) => infer ReturnType 
        ? ReturnType 
        : never;

重新构造做变换

TypeScript 类型系统可以通过 type 声明类型变量,通过 infer 声明局部变量,类型参数在类型编程中也相当于局部变量,但是它们都不能做修改,想要对类型做变换只能构造一个新的类型,在构造的过程中做过滤和转换。

在字符串、数组、函数、索引等类型都有很多应用,特别是索引类型。

比如把索引变为大写:

type UppercaseKey<Obj extends Record<string, any>> = { 
    [Key in keyof Obj as Uppercase<Key & string>]: Obj[Key]
}

递归复用做循环

在 TypeScript 类型编程中,遇到数量不确定问题时,就要条件反射的想到递归,每次只处理一个类型,剩下的放到下次递归,直到满足结束条件,就处理完了所有的类型。

比如把长度不确定的字符串转为联合类型:

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

数组长度做计数

TypeScript 类型系统没有加减乘除运算符,但是可以构造不同的数组再取 length 来得到相应的结果。这样就把数值运算转为了数组类型的构造和提取。

比如实现减法:

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

type Subtract<Num1 extends number, Num2 extends number> = 
    BuildArray<Num1> extends [...arr1: BuildArray<Num2>, ...arr2: infer Rest]
        ? Rest['length']
        : never;

联合分散可简化

TypeScript 对联合类型做了特殊处理,当遇到字符串类型或者作为类型参数出现在条件类型左边的时候,会分散成单个的类型传入做计算,最后把计算结果合并为联合类型。

type UppercaseA<Item extends string> = 
    Item extends 'a' ?  Uppercase<Item> : Item;

这样虽然简化了类型编程,但也带来了一些认知负担。

比如联合类型的判断是这样的:

type IsUnion<A, B = A> =
    A extends A
        ? [B] extends [A]
            ? false
            : true
        : never

联合类型做为类型参数直接出现在条件类型左边的时候就会触发 distributive 特性,而不是直接出现在左边的时候不会。

所以, A 是单个类型、B 是整个联合类型。通过比较 A 和 B 来判断联合类型。

特殊特性要记清

会了提取、构造、递归、数组长度计数、联合类型分散这 5 个套路以后,各种类型体操都能写,但是有一些特殊类型的判断需要根据它的特性来,所以要重点记一下这些特性。

比如 any 和任何类型的交叉都为 any,可以用来判断 any 类型:

type IsAny<T> = 'dong' extends ('guang' & T) ? true : false

比如索引一般是 string,而可索引签名不是,可以根据这个来过滤掉可索引签名:

type RemoveIndexSignature<Obj extends Record<string, any>> = {
  [
      Key in keyof Obj 
          as Key extends `${infer Str}`? Str : never
  ]: Obj[Key]
}

基础扎实套路熟,类型体操可通关

基础指的是 TypeScript 类型系统中的各种类型,以及可以对它们做的各种类型运算逻辑,这是类型编程的原材料。

但是只是会了基础不懂一些套路也很难做好类型编程,所以要熟悉上面 6 种套路。

基础扎实、套路也熟了之后,各种类型编程问题都可以搞定,也就是“通关”。

练练手

在讲 “TypeScript 类型编程为什么被叫做类型体操” 的时候我举了一个 ParseQueryString 的类型例子,用来说明类型编程的复杂度。

学完了所有套路之后,我们来实现下这个类型:

ParseQueryString

a=1&b=2&c=3&d=4,这样的字符串明显是 query param 个数不确定的,遇到数量不确定的问题,条件反射的就要想到递归:

递归解析出每一个 query params,也就是 & 分隔的每个字符串,每个字符串单独去解析,构造成索引类型,最后把这些所有的单个索引类型合并就行。

也就是这样的:

第一步并不知道有多少个 a=1、b=2 这种 query param,要递归的做模式匹配来提取。

然后每一个 query param 再通过模式匹配取出 key 和 value,构造成索引类型。

然后把每个索引类型合并成一个大的索引类型就可以了。

思路理清了,我们一步步来实现下。

首先,要递归的提取 & 分隔的 query param:

type ParseQueryString<Str extends string> = 
    Str extends `${infer Param}&${infer Rest}`
        ? MergeParams<ParseParam<Param>, ParseQueryString<Rest>>
        : ParseParam<Str>;

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

提取 & 分割的字符串到 infer 声明的局部变量 Param 里,后面的字符串放到 Rest 里。

通过 ParseParam 来处理单个的 query param,剩下 query 字符串也是一样的递归处理,然后把这些处理结果合并到一起,也就是 MergeParams。

当提取不出 & 分割的字符串时递归结束,把剩下的字符串也用 ParseParam 来处理。

ParseParam 的实现就是提取和构造:

type ParseParam<Param extends string> = 
    Param extends `${infer Key}=${infer Value}`
        ? {
            [K in Key]: Value 
        } : {};

类型参数 Param 类单个的 query param,比如 a=1 这种。

通过模式匹配提取 key 和 value 到 infer 声明的局部变量 Key、Value 里。

通过映射类型语法构造成索引类型返回:

试一下

每个 query param 处理完了,最后把这一系列构造出的索引类型合并成一个就行了:

这也是构造索引类型:

type MergeParams<
    OneParam extends Record<string, any>,
    OtherParam extends Record<string, any>
> = {
  [Key in keyof OneParam | keyof OtherParam]: 
    Key extends keyof OneParam
        ? Key extends keyof OtherParam
            ? MergeValues<OneParam[Key], OtherParam[Key]>
            : OneParam[Key]
        : Key extends keyof OtherParam 
            ? OtherParam[Key] 
            : never
}

类型参数 OneParam、OtherParam 是要合并的 query param,约束为索引类型(索引为 string,索引值为任意类型。

构造一个新的索引类型返回,索引来自两个的合并,也就是 Key in keyof OneParam | keyof OtherParam。

值也要做合并:

如果两个索引类型中都有,那就合并成一个,也就是 MergeValues<OneParam[Key], OtherParam[Key]>。

否则,如果是 OneParam 中的,就取 OneParam[Key],如果是 OtherParam 中的,就取 OtherParam[Key]。

MegeValues 的合并逻辑就是如果两个值是同一个就返回一个,否则构造一个数组类型来合并:

type MergeValues<One, Other> = 
    One extends Other 
        ? One
        : Other extends unknown[]
            ? [One, ...Other]
            : [One, Other];

类型参数 One、Other 是要合并的两个值。

如果两者是同一个类型,也就是 One extends Other,就返回任意一个。

否则,如果是数组就做数组合并,否则构造一个数组把两个类型放进去。

我们单独测试下索引合并:

试一下

每个 query param 的解析和构造索引类型,多个索引类型的合并都实现了,合并起来也就实现了 query string 的解析:

type ParseQueryString<Str extends string> = 
    Str extends `${infer Param}&${infer Rest}`
        ? MergeParams<ParseParam<Param>, ParseQueryString<Rest>>
        : ParseParam<Str>;

试一下

在实现 ParseQueryString 的类型的时候,我们大量用到了模式匹配做提取重新构造做变换递归复用做循环这 3 大套路,思路理清之后利用这些套路能够很顺畅的把这个高级类型写出来。

这是最开始被我用来说明类型编程复杂度的例子,是有一定复杂度的,而学到这我们也能实现了。

再回到最开始的问题:

TypeScript 类型编程难么?

其实熟悉一些套路以后,也没那么难。

总结

为了方便记忆,我总结了类型体操顺口溜,然后分别解释了每句话的含义,之后又做了一个类型体操来练手。

那个最开始被我用来说明 TypeScript 类型编程复杂度的例子,现在我们也能顺畅的实现了,所用的就是类型体操顺口溜中的套路。

这就像武功秘籍一样,理解了每句话的含义,反复修炼,就能成为类型体操的武林高手:

模式匹配做提取,重新构造做变换。

递归复用做循环,数组长度做计数。

联合分散可简化,特殊特性要记清。

基础扎实套路熟,类型体操可通关。

本文案例的合并

留言
Ctrl + Enter
全部评论(32)
万不能青年的头像
删除
GetReturnType 的例子,..args: unknown[] 应该改成..args: any[]吧,否则只能提取没有参数的函数的returnType。
点赞
1
删除
(作者)
确实,写错了,我改改
点赞
回复
hanxiaoxin的头像
删除
IT美食家 @ 天朝上国
自己拆分比较细实现了一个。大佬的思路我还要多看看才能理解[捂脸]
2
回复
三郎mr的头像
删除
深入浅出,通俗易懂
点赞
回复
程序员紫菜苔的头像
删除
打卡
点赞
回复
带带大菜鸡的头像
删除
乞讨工程师
打卡
点赞
回复
很帅很愁人的头像
删除
全村工程师 @ 下王庄村委员会
多练习吧
点赞
回复
jayafs1的头像
删除
TypeScript 对联合类型做了特殊处理,当遇到字符串类型或者作为类型参数出现在条件类型左边的时候,会分散成单个的类型传入做计算,最后把计算结果合并为联合类型。
这里的 ‘当遇到字符串类型’是个什么情况?没理解这句话,能否解释下?
点赞
2
删除
类似于这里,Item是一个联合类型,extends 后边是 `${infer Str}` 或者是一个具体的字符串的话。则此时这个Item就是一个一个的作比较的。
我的理解是这样。欢迎纠正
点赞
回复
删除
套路五:联合分散可简化 juejin.cn
里面有个例子,你直接试试:
type Combination<A extends string, B extends string> =
| A
| B
| `${A}${B}`
| `${B}${A}`;

type test = Combination<'A', 'B' | 'C'>; //打印的结果为: "A" | "B" | "C" | "AB" | "AC" | "BA" | "CA"

以上例子可用来说明 当数字类型 遇到 联合类型 时,触发循环单独计算
展开
点赞
回复
shadowMike的头像
删除
自己尝试写了一遍query string,没完全写出来[流汗]把keys、values分别转成2个数组后就卡住不知道如何转成对象了。。隔断时间再来实现一次。
点赞
回复
爱吃鱼的桶哥Z的头像
删除
伪 · 全栈打杂攻城狮
打开,还是需要多练习才行。
点赞
回复
国立的头像
删除
很有收获。
一个小疑惑想反馈下,构造的这个图,a,b的值好像没有和上面对上?
点赞
2
删除
(作者)
图画错了,我改改
点赞
回复
删除
还没改[泪奔]
图画错了,我改改
点赞
回复
油条的头像
删除
前端工程师
打卡[呲牙]
点赞
回复
西陵的头像
删除
前端 @ 百度
“MegeValues 的合并逻辑就是如果两个值是同一个就返回一个,否则构造一个数组类型来合并”,这个为什么要构造一个数组呢,不应该是一个联合类型吗
点赞
1
删除
(作者)
都行
点赞
回复
SPA枸杞泡脚盆的头像
删除
期待实战环节。。
点赞
回复
ShidongZhao的头像
删除
前端开发
光之呼吸
点赞
回复
是心动ya的头像
删除
菜狗
看完了敲一遍[抱拳]刷题
点赞
1
删除
(作者)
[碰拳]
点赞
回复
nxl的头像
删除
前端开发
看完了[吃瓜群众]
点赞
回复
年年呀的头像
删除
啥都懂一些的前端
学完了 敲完案例 结合了一下整了个 filterObjByKeyArr 的 util type 好玩~
点赞
2
删除
(作者)
[赞]
点赞
回复
删除
(作者)
这也太快了 [皱眉]
点赞
回复
spark_l的头像
删除
打卡
点赞
回复
芹菜006的头像
删除
少年,我看你骨骼精奇,是万中无一的武学奇才!维护世界和平就靠你了,我这有本秘籍--->《TypeScript 类型体操通关秘籍》,见与你有缘,就二十块卖给你了![奸笑][奸笑][奸笑]
1
2
删除
(作者)
[大笑]
点赞
回复
删除
[大笑]
点赞
回复
LeoFitz的头像
删除
前端工程师 @ 深圳某摸鱼公司
学会了
1
1
删除
(作者)
可以把案例代码都敲一敲
点赞
回复