06-套路二:重新构造做变换
课程
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 类型系统支持 3 种可以声明任意类型的变量: type、infer、类型参数。

type 叫做类型别名,其实就是声明一个变量存储某个类型:

type ttt = Promise<number>;

infer 用于类型的提取,然后存到一个变量里,相当于局部变量:

type GetValueType<P> = P extends Promise<infer Value> ? Value : never;

类型参数用于接受具体的类型,在类型运算中也相当于局部变量:

type isTwo<T> = T extends 2 ? true: false;

但是,严格来说这三种也都不叫变量,因为它们不能被重新赋值。

TypeScript 设计可以做类型编程的类型系统的目的就是为了产生各种复杂的类型,那不能修改怎么产生新类型呢?

答案是重新构造。

这就涉及到了第二个类型体操套路:重新构造做变换。

重新构造

TypeScript 的 type、infer、类型参数声明的变量都不能修改,想对类型做各种变换产生新的类型就需要重新构造。

数组、字符串、函数等类型的重新构造比较简单。

索引类型,也就是多个元素的聚合类型的重新构造复杂一些,涉及到了映射类型的语法。

我们先从简单的开始:

数组类型的重新构造

Push

有这样一个元组类型:

type tuple = [1,2,3];

我想给这个元组类型再添加一些类型,怎么做呢?

TypeScript 类型变量不支持修改,我们可以构造一个新的元组类型:

type Push<Arr extends  unknown[], Ele> = [...Arr, Ele];

类型参数 Arr 是要修改的数组/元组类型,元素的类型任意,也就是 unknown。

类型参数 Ele 是添加的元素的类型。

返回的是用 Arr 已有的元素加上 Ele 构造的新的元组类型。

试一下

这就是数组/元组的重新构造。

数组和元组的区别:数组类型是指任意多个同一类型的元素构成的,比如 number[]、Array<number>,而元组则是数量固定,类型可以不同的元素构成的,比如 [1, true, 'guang']。

Unshift

可以在后面添加,同样也可以在前面添加:

type Unshift<Arr extends  unknown[], Ele> = [Ele, ...Arr];

试一下

这两个案例比较简单,我们来做一个复杂的:

Zip

有这样两个元组:

type tuple1 = [1,2];
type tuple2 = ['guang', 'dong'];

我们想把它们合并成这样的元组:

type tuple = [[1, 'guang'], [2, 'dong']];

思路很容易想到,提取元组中的两个元素,构造成新的元组:

type Zip<One extends [unknown, unknown], Other extends [unknown, unknown]> = 
    One extends [infer OneFirst, infer OneSecond]
        ? Other extends [infer OtherFirst, infer OtherSecond]
            ? [[OneFirst, OtherFirst], [OneSecond, OtherSecond]] :[] 
                : [];

两个类型参数 One、Other 是两个元组,类型是 [unknown, unknown],代表 2 个任意类型的元素构成的元组。

通过 infer 分别提取 One 和 Other 的元素到 infer 声明的局部变量 OneFirst、OneSecond、OtherFirst、OtherSecond 里。

用提取的元素构造成新的元组返回即可:

试一下

但是这样只能合并两个元素的元组,如果是任意个呢?

那就得用递归了:

type Zip2<One extends unknown[], Other extends unknown[]> = 
    One extends [infer OneFirst, ...infer OneRest]
        ? Other extends [infer OtherFirst, ...infer OtherRest]
            ? [[OneFirst, OtherFirst], ...Zip2<OneRest, OtherRest>]: []
                : [];

类型参数 One、Other 声明为 unknown[],也就是元素个数任意,类型任意的数组。

每次提取 One 和 Other 的第一个元素 OneFirst、OtherFirst,剩余的放到 OneRest、OtherRest 里。

用 OneFirst、OtherFirst 构造成新的元组的一个元素,剩余元素继续递归处理 OneRest、OtherRest。

这样,就能处理任意个数元组的合并:

试一下

了解了数组类型的重新构造,我们再来看下字符串类型的:

字符串类型的重新构造

CapitalizeStr

我们想把一个字符串字面量类型的 'guang' 转为首字母大写的 'Guang'。

需要用到字符串类型的提取和重新构造:

type CapitalizeStr<Str extends string> = 
    Str extends `${infer First}${infer Rest}` 
        ? `${Uppercase<First>}${Rest}` : Str;

我们声明了类型参数 Str 是要处理的字符串类型,通过 extends 约束为 string。

通过 infer 提取出首个字符到局部变量 First,提取后面的字符到局部变量 Rest。

然后使用 TypeScript 提供的内置高级类型 Uppercase 把首字母转为大写,加上 Rest,构造成新的字符串类型返回。

试一下

这就是字符串类型的重新构造:从已有的字符串类型中提取出一些部分字符串,经过一系列变换,构造成新的字符串类型。

CamelCase

我们再来实现 dong_dong_dong 到 dongDongDong 的变换。

同样是提取和重新构造:

type CamelCase<Str extends string> = 
    Str extends `${infer Left}_${infer Right}${infer Rest}`
        ? `${Left}${Uppercase<Right>}${CamelCase<Rest>}`
        : Str;

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

提取 _ 之前和之后的两个字符到 infer 声明的局部变量 Left 和 Right,剩下的字符放到 Rest 里。

然后把右边的字符 Right 大写,和 Left 构造成新的字符串,剩余的字符 Rest 要继续递归的处理。

这样就完成了从下划线到驼峰形式的转换:

试一下

DropSubStr

可以修改自然也可以删除,我们再来做一个删除一段字符串的案例:删除字符串中的某个子串

type DropSubStr<Str extends string, SubStr extends string> = 
    Str extends `${infer Prefix}${SubStr}${infer Suffix}` 
        ? DropSubStr<`${Prefix}${Suffix}`, SubStr> : Str;

类型参数 Str 是待处理的字符串, SubStr 是要删除的字符串,都通过 extends 约束为 string 类型。

通过模式匹配提取 SubStr 之前和之后的字符串到 infer 声明的局部变量 Prefix、Suffix 中。

如果不匹配就直接返回 Str。

如果匹配,那就用 Prefix、Suffix 构造成新的字符串,然后继续递归删除 SubStr。直到不再匹配,也就是没有 SubStr 了。

试一下

字符串类型的重新构造之后,我们再来看下函数类型的重新构造:

函数类型的重新构造:

AppendArgument

之前我们分别实现了参数和返回值的提取,那么重新构造就是用这些提取出的类型做下修改,构造一个新的类型即可。

比如在已有的函数类型上添加一个参数:

type AppendArgument<Func extends Function, Arg> = 
    Func extends (...args: infer Args) => infer ReturnType 
        ? (...args: [...Args, Arg]) => ReturnType : never;

类型参数 Func 是待处理的函数类型,通过 extends 约束为 Function,Arg 是要添加的参数类型。

通过模式匹配提取参数到 infer 声明的局部变量 Args 中,提取返回值到局部变量 ReturnType 中。

用 Args 数组添加 Arg 构造成新的参数类型,结合 ReturnType 构造成新的函数类型返回。

这样就完成了函数类型的修改:

试一下

最后,我们再来看下索引类型的重新构造

索引类型的重新构造

索引类型是聚合多个元素的类型,class、对象等都是索引类型,比如这就是一个索引类型:

type obj = {
  name: string;
  age: number;
  gender: boolean;
}

索引类型可以添加修饰符 readonly(只读)、?(可选):

type obj = {
  readonly name: string;
  age?: number;
  gender: boolean;
}

对它的修改和构造新类型涉及到了映射类型的语法:

type Mapping<Obj extends object> = { 
    [Key in keyof Obj]: Obj[Key]
}

Mapping

映射的过程中可以对 value 做下修改,比如:

type Mapping<Obj extends object> = { 
    [Key in keyof Obj]: [Obj[Key], Obj[Key], Obj[Key]]
}

类型参数 Obj 是待处理的索引类型,通过 extends 约束为 object。

用 keyof 取出 Obj 的索引,作为新的索引类型的索引,也就是 Key in keyof Obj。

值的类型可以做变换,这里我们用之前索引类型的值 Obj[Key] 构造成了三个元素的元组类型 [Obj[Key], Obj[Key], Obj[Key]]:

试一下

索引类型的映射画下图很容易理解:

UppercaseKey

除了可以对 Value 做修改,也可以对 Key 做修改,使用 as,这叫做重映射

比如把索引类型的 Key 变为大写。

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

类型参数 Obj 是待处理的索引类型,通过 extends 约束为 object。

新的索引类型的索引为 Obj 中的索引,也就是 Key in keyof Obj,但要做一些变换,也就是 as 之后的。

通过 Uppercase 把索引 Key 转为大写,因为索引可能为 string、number、symbol 类型,而这里只能接受 string 类型,所以要 & string,也就是取索引中 string 的部分。

value 保持不变,也就是之前的索引 Key 对应的值的类型 Obj[Key]。

这样构造出的新的索引类型,就把原来索引类型的索引转为了大写:

试一下

Record

TypeScript 提供了内置的高级类型 Record 来创建索引类型:

type Record<K extends string | number | symbol, T> = { [P in K]: T; }

指定索引和值的类型分别为 K 和 T,就可以创建一个对应的索引类型。

上面的索引类型的约束我们用的 object,其实更语义化一点我推荐用 Record<string, any>:

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

也就是约束类型参数 Obj 为 key 为 string,值为任意类型的索引类型。

ToReadonly

索引类型的索引可以添加 readonly 的修饰符,代表只读。

那我们就可以实现给索引类型添加 readonly 修饰的高级类型:

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

通过映射类型构造了新的索引类型,给索引加上了 readonly 的修饰,其余的保持不变,索引依然为原来的索引 Key in keyof T,值依然为原来的值 T[Key]。

试一下

ToPartial

同理,索引类型还可以添加可选修饰符:

type ToPartial<T> = {
    [Key in keyof T]?: T[Key]
}

给索引类型 T 的索引添加了 ? 可选修饰符,其余保持不变。

试一下

ToMutable

可以添加 readonly 修饰,当然也可以去掉:

type ToMutable<T> = {
    -readonly [Key in keyof T]: T[Key]
}

给索引类型 T 的每个索引去掉 readonly 的修饰,其余保持不变。

试一下

ToRequired

同理,也可以去掉可选修饰符:

type ToRequired<T> = {
    [Key in keyof T]-?: T[Key]
}

给索引类型 T 的索引去掉 ? 的修饰 ,其余保持不变。

试一下

FilterByValueType

可以在构造新索引类型的时候根据值的类型做下过滤:

type FilterByValueType<
    Obj extends Record<string, any>, 
    ValueType
> = {
    [Key in keyof Obj 
        as Obj[Key] extends ValueType ? Key : never]
        : Obj[Key]
}

类型参数 Obj 为要处理的索引类型,通过 extends 约束为索引为 string,值为任意类型的索引类型 Record<string, any>。

类型参数 ValueType 为要过滤出的值的类型。

构造新的索引类型,索引为 Obj 的索引,也就是 Key in keyof Obj,但要做一些变换,也就是 as 之后的部分。

如果原来索引的值 Obj[Key] 是 ValueType 类型,索引依然为之前的索引 Key,否则索引设置为 never,never 的索引会在生成新的索引类型时被去掉。

值保持不变,依然为原来索引的值,也就是 Obj[Key]。

这样就达到了过滤索引类型的索引,产生新的索引类型的目的:

试一下

总结

TypeScript 支持 type、infer、类型参数来保存任意类型,相当于变量的作用。

但其实也不能叫变量,因为它们是不可变的。想要变化就需要重新构造新的类型,并且可以在构造新类型的过程中对原类型做一些过滤和变换。

数组、字符串、函数、索引类型等都可以用这种方式对原类型做变换产生新的类型。其中索引类型有专门的语法叫做映射类型,对索引做修改的 as 叫做重映射。

提取和构造这俩是相辅相成的,学完了模式匹配做提取重新构造做变换 这两个套路之后,很多类型体操就有思路了。

本文案例的合并

留言
Ctrl + Enter
全部评论(75)
hedgehog_boy的头像
删除
这里是不是写错了,应该是约束类型参数Obj的key为string,值为任意类型的索引类型
点赞
回复
万不能青年的头像
删除
`其实更语义化一点我推荐用 Record<string, object>` 这里是不是写错了,应该是Record<string, any>
点赞
1
删除
(作者)
确实,我改改
点赞
回复
感觉时刻的头像
删除
不错,清晰明了
点赞
回复
hedgehog_boy的头像
删除
跟着敲完了,也理解了,但是自己单独写还是有问题的,不过熟练,需要多写,看到这里,之前对TS很多类型不懂的也变得更理解了一些;例子通俗易懂,很不错。
1
回复
啊Ben学前端的头像
删除
前端 @ 字节跳动
打卡
点赞
回复
火车_叨位去的头像
删除
前端
这里的key & string约束不了Symbol类型吗,为什么Symbol类型的sym也被转换了
点赞
3
删除
这样使用 Symbol,你图中的其实相当于 {'sym': '5'}
1
回复
删除
那怎么写symbol类型才能过滤呢
这样使用 Symbol,你图中的其实相当于 {'sym': '5'}
点赞
回复
查看更多回复
FranklinTesla的头像
删除
前端 @ OPPO
type DropSubStr<Str extends string, SubStr extends string> = Str extends `${infer Prefix}${SubStr}${infer Suffix}` ? `${Prefix}${DropSubStr<Suffix, SubStr>}` : Str
递归的时候是不是不用把Prefix再传下去
1
1
删除
是滴 Prefix 是默认处理好的
点赞
回复
端同志的头像
删除
同问,最后一个demo,如图,r1和r3的结果是正确的{name:string,id:number},r2的结果是{}
点赞
5
删除
r3:
点赞
回复
删除
ValueType是string|number,是ValueType extends Obj[key] 会为false,所以r2为{}
r3:
1
回复
查看更多回复
用户5585503287918的头像
删除
全菜
默默地问一下,type CapitalizeStr<Str extends string> =
Str extends `${infer First}${infer Rest}`
? `${Uppercase<First>}${Rest}` : Str; 这个First是怎么确保只提取一个字符的。。
1
2
删除
我试了一下 好像是 默认一个infer取一个字符 最后一个infer 就是 rest
比如
'abcdefg'
`${infer First}${infer Sencord}${infer Rest}`
Firtst = 'a'
Secord = 'b'
Rest = 'cdefg'
展开
点赞
回复
删除
我理解为占位符
我试了一下 好像是 默认一个infer取一个字符 最后一个infer 就是 rest
比如
'abcdefg'
`${infer First}${infer Sencord}${infer Rest}`
Firtst = 'a'
Secord = 'b'
Rest = 'cdefg'
点赞
回复
王同超的头像
删除
没有理解这里把any换成unknown就报错是为什么?其他的实例换成unkonwn没有问题。有知道的小伙伴帮忙解惑一下嘛?
点赞
7
删除
Person不就是字符串索引签名嘛?[疑问]
点赞
回复
删除
这么写就不会报错...
点赞
回复
查看更多回复
小鱼儿亮亮的头像
删除
前端工程师
可以实现”给上例中ToReadonly加一个参数,指定给哪几个key添加readonly“吗?
1
2
删除
(作者)
可以的,综合实战一里有个类似的
1
回复
删除
太强了
[震惊]
可以的,综合实战一里有个类似的
点赞
回复
梦愿的头像
删除
摸鱼小前端
最后一个demo:FilterByValueType
类型编程的核心断言操作:as ValueType extends Obj[Key] 交换下 extends 两边的类型 =>
as Obj[Key] extends ValueType 不会影响结果,交换后感觉更好理解,即 Obj[Key] 继承自 ValueType 。不知道我这样理解有没有问题,期望得到解答~
5
7
删除
联合类型 extends OtherType 我记得可以看作是其中的每一个类型分别判断继承关系,存在一个继承关系即返回true。
点赞
回复
删除
(作者)
在映射类型里的 as 叫重映射,是用于对索引做过滤和修改的。
1
回复
查看更多回复
爱吃鱼的桶哥Z的头像
删除
伪 · 全栈打杂攻城狮
这个图中,第一处画红线的地方,应该是any吧,object在这里应该有问题;
第二处画红线的地方叙述是不是该改一下:【约束类型参数 Obj 为 key 是 string 的值】这样是不是好理解一点?
点赞
3
删除
(作者)
没问题呀,key 是 string 类型
点赞
回复
删除
我的意思是,Object为 Key为string,这句话可以考虑换一下,读起来会更顺畅。
没问题呀,key 是 string 类型
点赞
回复
查看更多回复
lxb不想说话的头像
删除
强的一批
点赞
回复
shadowMike的头像
删除
吸收反刍中[思考]
点赞
1
删除
可以,再看一次时又掌握了不少。现在能看着题目自己做体操了。
1
回复
木又寸不想说话的头像
删除
DropSubStr,这个假如删除前是donngg,子串是ng,一开始删除ng,变成dong,这时候还会继续删原本不连续的ng,感觉有bug
5
1
删除
看下面我的那个写法就不会有这个问题
点赞
回复
安静的say的头像
删除
每天进步一点点的前端工程师
DropSubStr 这个是不是可以优化成下面这样写
type DropSubStr<Str extends string, SubStr extends string> = Str extends `${infer Left}${SubStr}${infer Right}` ? `${Left}${DropSubStr<Right, SubStr>}` : Str;
6
2
删除
感觉可以,已经处理过的字符就不在进行处理
点赞
回复
删除
[色]
点赞
回复
atomic的头像
删除
前端工程师
//tuple to set */写了个小方法
type tuple = [1,2,3];
type push<arr extends unknown[], ele> = ele extends keyof arr ? [...arr] : [...arr, ele];
type pushResult = push<tuple, 3>
1
回复
吃啥啥不剩的头像
删除
前端干啥啥不行工程师
`${infer First}${infer Rest}`
这个怎么理解呢,为什么 First 就是第一个字母呢,而不是前两个,前三个?
点赞
4
删除
是不是最后一个变量默认就是 rest
点赞
回复
删除
(作者)
如果不加啥分割字符,那默认匹配第一个
点赞
回复
查看更多回复
赝品007的头像
删除
在长期主义之路上,与伟大格局观者同行,做时间的朋友。
DropSubStr的结果是“do”,应该是“dongdo”,这个删除有点问题。
点赞
回复

查看全部 75 条回复