15-类型编程综合实战二
课程
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.

想提升类型编程水平,还是要多做一些有难度的案例,这节我们提高下难度,加大训练强度。

函数重载的三种写法

ts 支持函数重载,也就是同名的函数可以有多种类型定义。

重载的写法一共有三种:

declare function func(name: string): string;
declare function func(name: number): number;

这种大家比较常用,声明两个同名函数,就能达到重载的目的:

函数可以用 interface 的方式声明,同样,也可以用 interface 的方式声明函数重载:

函数类型可以取交叉类型,也就是多种类型都可以,其实也是函数重载的意思:

试一下

这里讲函数重载是为了下面这个复杂类型做铺垫的:

UnionToTuple

要求把联合类型转成元组类型,大家有思路没?

也就是 'a' | 'b' | 'c' 转成 ['a', 'b', 'c']。

没思路很正常,因为这里用到了一些特殊的特性。我们先来过一下用到的特性:

我们知道 ReturnType 是 ts 内置的一个高级类型,它可以取到函数返回值的类型。但如果这个函数有多个重载呢?

第一种重载方式:

第二种重载方式:

第三种重载方式:

取重载函数的 ReturnType 返回的是最后一个重载的返回值类型。

但这与联合类型有什么关系呢?

重载函数不是能通过函数交叉的方式写么,而我们又能实现联合转交叉。

所以就能拿到联合类型的最后一个类型:

type UnionToIntersection<U> = 
    (U extends U ? (x: U) => unknown : never) extends (x: infer R) => unknown
        ? R
        : never

type UnionToFuncIntersection<T> = UnionToIntersection<T extends any ? () => T : never>;

UnionToIntersection 的实现在套路六里讲了,忘了的可以去翻一下。

这里简单讲一下:U extends U 是触发分布式条件类型,构造一个函数类型,通过模式匹配提取参数的类型,利用函数参数的逆变的性质,就能实现联合转交叉。

因为函数参数的类型要能接收多个类型,那肯定要定义成这些类型的交集,所以会发生逆变,转成交叉类型。

然后是 UnionToFuncIntersection 的类型:

我们对联合类型 T 做下处理,用 T extends any 触发分布式条件类型的特性,它会把联合类型的每个类型单独传入做计算,最后把计算结果合并成联合类型。把每个类型构造成一个函数类型传入。

这样,返回的交叉类型也就达到了函数重载的目的:

然后再通过 ReturnType 取返回值的类型,就取到了联合类型的最后一个类型:

取到最后一个类型后,再用 Exclude 从联合类型中把它去掉,然后再同样的方式取最后一个类型,构造成元组类型返回,这样就达到了联合转元组的目的:

type UnionToTuple<T> = 
    UnionToIntersection<
        T extends any ? () => T : never
    > extends () => infer ReturnType
        ? [...UnionToTuple<Exclude<T, ReturnType>>, ReturnType]
        : [];

类型参数 T 为待处理的联合类型。

T extends any 触发了分布式条件类型,会把每个类型单独传入做计算,把它构造成函数类型,然后转成交叉类型,达到函数重载的效果。

通过模式匹配提取出重载函数的返回值类型,也就是联合类型的最后一个类型,放到数组里。

通过 Exclude 从联合类型中去掉这个类型,然后递归的提取剩下的。

这样就完成了联合转元组的目的:

试一下

回过头来看一下,联合类型的处理之所以麻烦,是因为不能直接 infer 来取其中的某个类型,我们是利用了取重载函数的返回值类型拿到的是最后一个重载类型的返回值这个特性,把联合类型转成交叉类型来构造重载函数,然后取返回值类型的方式来取到的最后一个类型。然后加上递归,就实现了所有类型的提取。

join

不知道大家是否还记得“类型编程的意义”那节的 currying 函数的类型定义,那个还是挺复杂的。我们再来做个类似的练习。

const res = join('-')('guang', 'and', 'dong');

有这样一个 join 函数,它是一个高阶函数,第一次调用传入分隔符,第二次传入多个字符串,然后返回它们 join 之后的结果。

比如上面的 res 是 guang-and-dong。

如果要给这样一个 join 函数加上类型定义应该怎么加呢?要求精准的提示函数返回值的类型。

我们可以这样来定义这个类型:

declare function join<
    Delimiter extends string
>(delimiter: Delimiter):
    <Items extends string[]>
        (...parts: Items) => JoinType<Items, Delimiter>;

类型参数 Delimiter 是第一次调用的参数的类型,约束为 string。

join 的返回值是一个函数,也有类型参数。类型参数 Items 是返回的函数的参数类型。

返回的函数类型的返回值是 JoinType 的计算结果,传入两次函数的参数 Delimiter 和 Items。

这里的 JoinType 的实现就是根据字符串元组构造字符串,用到提取和构造,因为数量不确定,还需要递归。

所以 JoinType 高级类型的实现就是这样的:

type JoinType<
    Items extends any[],
    Delimiter extends string,
    Result extends string = ''
> = Items extends [infer Cur, ...infer Rest]
        ? JoinType<Rest, Delimiter, `${Result}${Delimiter}${Cur & string}`>
        : RemoveFirstDelimiter<Result>;

类型参数 Items 和 Delimiter 分别是字符串元组和分割符的类型。Result 是用于在递归中保存中间结果的。

通过模式匹配提取 Items 中的第一个元素的类型到 infer 声明的局部变量 Cur,后面的元素的类型到 Rest。

构造字符串就是在之前构造出的 Result 的基础上,加上新的一部分 Delimiter 和 Cur,然后递归的构造。这里提取出的 Cur 是 unknown 类型,要 & string 转成字符串类型。

如果不满足模式匹配,也就是构造完了,那就返回 Result,但是因为多加了一个 Delimiter,要去一下。

type RemoveFirstDelimiter<
    Str extends string
> = Str extends `${infer _}${infer Rest}` 
        ? Rest
        : Str;

去掉开始的 Delimiter 就是个简单字符串字面量类型的提取,就不多解释了。

这样,就实现了 join 的类型定义:

试一下

索引类型是我们处理最多的类型,再来练习个索引类型的:

DeepCamelize

Camelize 是 guang-and-dong 转 guangAndDong,这个我们上节实现过。现在要求递归的把索引类型的 key 转成 CamelCase 的。

比如这样一个索引类型:

type obj = {
    aaa_bbb: string;
    bbb_ccc: [
        {
            ccc_ddd: string;
        },
        {
            ddd_eee: string;
            eee_fff: {
                fff_ggg: string;
            }
        }
    ]
}

要求转成这样:

type DeepCamelizeRes = {
    aaaBbb: string;
    bbbCcc: [{
        cccDdd: string;
    }, {
        dddEee: string;
        eeeFff: {
            fffGgg: string;
        };
    }];
}

这要求在 KebabCase 转 CamelCase 的基础上,加上索引类型的递归处理,比较综合。

我们实现下:

type DeepCamelize<Obj extends Record<string, any>> = 
    Obj extends unknown[]
        ? CamelizeArr<Obj>
        : { 
            [Key in keyof Obj 
                as Key extends `${infer First}_${infer Rest}`
                    ? `${First}${Capitalize<Rest>}`
                    : Key
            ] : DeepCamelize<Obj[Key]> 
        };

类型参数 Obj 为待处理的索引类型,约束为 Record<string, any>。

判断下是否是数组类型,如果是的话,用 CamelizeArr 处理。

否则就是索引类型,用映射类型的语法来构造新的索引类型,Key 为之前的 Key,也就是 Key in keyof Obj,但要做一些变化,也就是 as 重映射之后的部分。

这里的 KebabCase 转 CamelCase 就是提取 _ 之前的部分到 First,之后的部分到 Rest,然后构造新的字符串字面量类型,对 Rest 部分做首字母大写,也就是 Capitialize。

值的类型 Obj[Key] 要递归的处理,也就是 DeepCamelize<Obj[Key]>。

其中的 CamelizeArr 的实现就是递归处理每一个元素:

type CamelizeArr<Arr> = Arr extends [infer First, ...infer Rest]
    ? [DeepCamelize<First>, ...CamelizeArr<Rest>]
    : []

通过模式匹配提取 Arr 的第一个元素的类型到 First,剩余元素的类型到 Rest。

处理 First 放到数组中,剩余的递归处理。

这样我们就实现了索引类型的递归 Camelize:

试一下

最后再练习下内置的高级类型,这些比较常用:

Defaultize

实现这样一个高级类型,对 A、B 两个索引类型做合并,如果是只有 A 中有的不变,如果是 A、B 都有的就变为可选,只有 B 中有的也变为可选。

比如下面这样:

aaa 是只有 A 有的,所以不变。

bbb 是两者都有的,变为可选。

ccc 是只有 B 有的,变为可选。

怎么实现这样的高级类型呢?

索引类型处理可以 Pick 出每一部分单独处理,最后取交叉类型来把处理后的索引类型合并到一起。

上面的类型就可以这样实现:

type Defaultize<A, B> = 
    & Pick<A, Exclude<keyof A, keyof B>>
    & Partial<Pick<A, Extract<keyof A, keyof B>>>
    & Partial<Pick<B, Exclude<keyof B, keyof A>>>

Pick 出 A、B 中只有 A 有的部分,也就是去 A 中去掉了 B 的 key: Exclude<keyof A, keyof B>。

然后 Pick 出 A、B 都有的部分,也就是 Extract<keyof A, keyof B>。用 Partial 转为可选。

之后 Pick 出只有 B 有的部分,也就是 Exclude<keyof B, keyof A>。用 Partial 转为可选。

最后取交叉类型来把每部分的处理结果合并到一起。

这样就实现了我们的需求:

为啥这里没显示最终的类型呢?

因为 ts 只有在类型被用到的时候才会去做类型计算,根据这个特点,我们可以用映射类型的语法构造一个一摸一样的索引类型来触发类型计算。

type Copy<Obj extends Record<string, any>> = {
    [Key in keyof Obj]: Obj[Key]
}

这就是标准的映射类型的语法,就不多解释了。

这样就能看到最终的类型:

试一下

总结

这节我们又做了很多高难度的案例,提高练习的难度才能更好的提升类型编程水平。

首先我们学会了函数重载的三种写法,这个是为后面的联合转元组做铺垫的,联合转元组利用了提取重载函数的返回值会返回最后一个重载的返回值类型的特性,通过联合转交叉构造出重载类型来提取的联合类型中的类型,然后递归的处理。

之后我们实现了 join 的类型定义,这个综合用到了元组的提取和字符串字面量类型的构造,再加上递归。

然后我们又实现了 DeepCamelize,递归的处理索引类型,把 Key 转为 CamelCase 的形式,比较综合的案例。

最后练习了内置的高级类型 Pick、Exclude、Extract、Partial 等,处理索引类型的常用套路就是 Pick 出每一部分单独做处理,最后取交叉类型把结果合并到一起

能够实现这些高难度的类型,相信你的类型编程水平就已经很不错了。

本文案例的合并

留言
Ctrl + Enter
全部评论(26)
hedgehog_boy的头像
删除
后面的有些例子很难理解[流泪]
点赞
回复
尾号i的头像
删除
前端工程师
打卡!
点赞
回复
MrXu_的头像
删除
前端工程师
想问一下有没有类似的 TS 库,引入到项目里自动支持这些函数呢?
点赞
1
删除
(作者)
ts-fest
1
回复
三郎mr的头像
删除
这里有个疑问:
1
7
删除
Obj[Key] 是值的类型啊, 你说的是 string 是key的类型了
点赞
回复
删除
//type obj = {
aaa_bbb: string;
}
//DeepCamelize 要求入参约束为extends Record<string, any>;
//当Key为 aaa_bbb 时的,Obj[key]的值为 string ,
此时调用DeepCamelize<Obj[key]> ,这个Obj[key]就不符合DeepCamelize类型函数的入参约束了,就会报错,测试如下:
type isMatchDeepCamelizeParamsType =
string extends Record<string, any> ? true:false //打印值为false
展开
Obj[Key] 是值的类型啊, 你说的是 string 是key的类型了
点赞
回复
查看更多回复
bigFace的头像
删除
发现单词错误,extends。“用 T extneds any 触发分布式条件类型的特性”,
点赞
1
删除
(作者)
感谢,我改改
点赞
回复
带带大菜鸡的头像
删除
乞讨工程师
打卡
点赞
回复
EEEEEEEEE的头像
删除
前端
"元组中提取的元素类型为 unknown,多个元素的类型为 unknown[]" 光 这个怎么理解呢
www.typescriptlang.org
点赞
1
删除
(作者)
那个案例里提取出来的类型就是 unknown 和 unknown[] 🤔,这句有点问题,我去掉了
点赞
回复
枫景的头像
删除
前端工程师 @ 杭州某工地
Defaultize那里,我咋感觉可以直接剔除A中B的属性,然后B所有的属性全都可选。
3
5
删除
(作者)
这个是我从 react 类型定义里抠出来的,他就是这么写的[吃瓜群众]
点赞
回复
删除
😂
这个是我从 react 类型定义里抠出来的,他就是这么写的[吃瓜群众]
点赞
回复
查看更多回复
零式大人的头像
删除
普通上班族
泛型 T extends T 和 T extends any 作用都是触发分布式条件对吧
点赞
1
删除
(作者)
对的
点赞
回复