探秘Typescript·常用的工具类型及其底层原理
背景
我们已经了解了Typescript
当中的一些常见的日常类型了,也知道了在Typescript
当中,有着极为强大的类型计算体系。使用这些日常类型,我们基本可以完成日常开发工作的80%左右的问题,而剩下的一些问题,都可以通过Typescript
当中的强大的类型计算体系来解决。但大多数是情况下,很多的场景,Typescript
已经帮我们考虑到了一些复杂场景,为我们预置了大量的工具类型,用以辅助我们完成更为复杂的类型计算。今天,我们就来详细得聊一聊这些预置的工具类型及其底层实现的原理。
Partial - 所有属性变可选
Partial
工具类型,可以将原本类型中的所有属性变成可选属性,方便我们更好的利用现有的类型演变出新的类型,例如:
type User = {
name: string;
age: number;
}
let user: User = {
name: "kiner",
age: 20
};
function updateInfo(newUser: Partial<User>): void {
user = {
...user,
...newUser
};
}
// 以下的表述方式是等效的
// Partial<User> = {name?: string; age?: number};
updateInfo({
name: "tang"
});
console.log(user);// {name: "tang", age: 20}
updateInfo({
age: 18
});
console.log(user);// {name: "tang", age: 18}
从上面的示例中我们可以看到,我们有一个基础的用户类型User
,我们期望在定义updateInfo
方法时,接受的参数,只要包含User
中的任意属性即可,无需传入所有属性。如果不使用Partial
工具,那么,我们可能还需要额外定一个新的类型去维护newUser
参数的类型,但使用Partial
后,我们就可以直接用User
计算出newUser
的类型,非常方便快捷,还可以有效利用已有类型进行计算,一旦User
对象的类型结构发生了改变,如增加了一个属性sex
,此时我们的newUser
的类型完全不用手动更改,自动就会支持新属性。
我们说过,Typescript
的类型都是可计算的,Partial
工具本质上可以看做是一个语法糖,封装了一些便捷的类型计算逻辑,那么,它底层是如何实现的呢?
/**
* Make all properties in T optional
*/
type Partial<T> = {
[P in keyof T]?: T[P];
};
我们可以看到,本质上,Partial
工具就是将传入的泛型变量T
当中的每一个属性都多加一个可选标记?
而已。就这么简单
Required - 所有属性变必选
与Partial
相反的,如果我们想让一个类型的所有属性都变为必选,就可以使用Required
,如:
type User = {
name?: string;
age?: number;
sex: string;
}
type AddUserParams = Required<User>;
// {name: string; age: number; sex: string;}
我们再来看看,Required
的底层实现逻辑:
/**
* Make all properties in T required
*/
type Required<T> = {
[P in keyof T]-?: T[P];
};
我们可以看到,该方法其实就是将传入的泛型参数当中的所有属性都进行了-?
操作,在Typescript
当中,-?
操作代表去除当前属性的可选标记。
Readonly - 所有属性标记为只读
在实际开发过程中,有一些数据我们不想被修改,就会将这些数据标记为readonly
,如果在一个类型所有属性都要标记为只读,那我们可以这样:
type User = {
name: string;
age: number;
}
type ReadonlyUser = Readonly<User>;
// {readonly name: string; readonly age: number}
其实底层原理跟上面的工具也都是很类似的:
/**
* Make all properties in T readonly
*/
type Readonly<T> = {
readonly [P in keyof T]: T[P];
};
Pick - 从一个类型中挑选目标属性
如果我们想要从一个类型当中挑选出若干个目标属性出来,我们可以使用此工具,如:
type User = {
name: string;
age: number;
sex: string;
};
type NewUser = Pick<User, "name" | "age">;
// {name: string, age: number}
const newUser: NewUser = {
name: "kiner",
age: 20
}
从上述示例中,我们可以看到,我们从User
当中挑选了name
和age
属性出来形成了新的类型NewUser
,此时,NewUser
当中就只有这两个属性,没有sex
属性了。
那么,我们再来看一下这个工具的原理吧:
/**
* From T, pick a set of properties whose keys are in the union K
*/
type Pick<T, K extends keyof T> = {
[P in K]: T[P];
};
我们可以看到,该工具从传入的泛型参数T
当中,挑选T
的键值子集范围的属性形成新的类型。这么说或许不太好理解,我们还是拿上面的示例来说。类型User
键值的全集是name | age | sex
,而我们传入的name | age
则是全集的一部分,也就是其子集,因此,上述的表达式的意思就是:从User
当中挑选出name
和age
属性出来形成一个新的类型。
Record - 记录(json)类型
如果我们想要在Typescript
当中描述一个键值都不确定的对象时,可以使用这个工具进行描述,它允许我们规定对象类型允许的键的类型以及所对应值的类型,如:
type Column = Record<string, string>;
const column: Column = {
name: "kiner",
age: 20,// 报错,因为我们规定了`Column`类型的值只能是`string`类型,如果要不报错,可以将类型定义为:Record<string, string | number>
20: "tang"// 报错,因为我们规定了`Column`类型的键名只能是`string`类型
};
这样,我们就能够定义一个起初不确定键名的对象了。那么,我们还是来看一下,这个工具,底层是如何实现的:
/**
* Construct a type with a set of properties K of type T
*/
type Record<K extends keyof any, T> = {
[P in K]: T;
};
我们可以看到,只要我们传入的对象的键的类型满足继承于keyof any
即string | number | symbol
,并且值的类型是T
即可。
Exclude - 将子集从全集当中排除
获取我们有些开发场景需要实现将全集中的某些元素排除在外,仅保留其余元素的情况,此时我们便可以使用这个工具,如:
type All = "name" | "age" | "sex";
type OnlySex = Exclude<All, "name" | "age">;// "sex";
我们可以看到上述的例子当中,将name
和age
从全集当中排除,因此,新类型OnlySex
当中就只有sex
属性了。
我们来看看这个工具是如何实现的:
/**
* Exclude from T those types that are assignable to U
*/
type Exclude<T, U> = T extends U ? never : T;
这边只需要判断T
是否是继承与U
的,如果不是继承于U
的,那么我们就保留这个属性,否则设置为永远不可达的类型never
.即:该工具类型能够从类型T
中剔除所有可以赋值给类型U
的类型
Extract - 获取两个集合的交集
此工具实际跟Exclude
是互补的,它能够从类型T
中获取所有可以赋值给类型U
的类型。如:
type All1 = "name" | "age" | "sex";
type All2 = "name" | "sex" | "weight";
type All3 = Extract<All1, All2>;// All1和All2 相交的部分是"name"和"sex",因此,All3的类型应该是"name" | "sex"
再来看看这个工具的底层原理吧:
/**
* Extract from T those types that are assignable to U
*/
type Extract<T, U> = T extends U ? T : never;
从实现上看,与Exclude
相比,仅仅只是将结果交换了而已。
Omit - 从类型中剔除部分属性
我们上面说的Exclude
类型,通常使用与联合类型排除相交部分的场景,但如果想要将一个对象类型的某些属性剔除,应该使用Omit
,如:
type User = {
id: number;
name: string;
age: number;
sex: string;
};
type AddUser = Omit<User, "id">;
例如我们定义了一个用户对象,用户本身是存在id
属性的,但是在新增用户的场景时,id
还没有生成,因此,在新增用户的时候,需要传除了id
之外的其他参数,那么,我们就可以用这个工具将id
属性从User
当中剔除掉。
那么,我们再来看看这个工具又是如何实现的呢?
/**
* Construct a type with the properties of T except for those in type K.
*/
type Omit<T, K extends keyof any> = Pick<T, Exclude<keyof T, K>>;
我们可以看到,这个工具的实现相交于上述的一些工具相对复杂一些,同时还是用到了上面讲到的Pick
工具和Exclude
工具。其思路大体是这样的:从T
当中挑选出除了给定的属性之外的其他属性出来,而指定需要剔除的属性也许要满足是键值的合法类型,即keyof any
,也就是string | number | symbol
。
Parameters - 获取函数的参数类型
有些时候,我们需要根据已经定义的函数获取他的参数类型,此时我们可以使用这个工具。如:
function showInfo(info: {name: string, age: number, sex?: string}) : void {
console.log(info);
}
type ShowInfoParams = Parameters<typeof showInfo>;
// [info: {
// name: string;
// age: number;
// sex?: string | undefined;
// }]
如上述示例,我们很轻易的就获取到了showInfo
方法的传入参数了。
我们来看看这个工具底层是如何实现的吧:
/**
* Obtain the parameters of a function type in a tuple
*/
type Parameters<T extends (...args: any) => any> = T extends (...args: infer P) => any ? P : never;
此处使用到了之前讲过的Typescript
当中的高级计算技巧infer
对类型进行推导,直接推导出目标函数的参数类型,我们可以看到,上面使用了...
运算符收集了所有参数的信息,这也是为什么我们上面推导出来的函数参数是一个数组的原因。
ReturnType - 获取函数的返回值
既然能够推导函数的传入参数,有进必有出,自然也可以推导出函数的返回值类型了。我们想要推导函数的返回值只需要使用ReturnType
工具即可,如:
function showInfo(info: {name: string, age: number, sex?: string}) : string {
console.log(info);
return info.name;
}
type ShowInfoReturnType = ReturnType<typeof showInfo>;// string
如上面的示例,我们很轻易的就推导出了showInfo
函数的返回值类型是string
。实际上,其底层原理与Parameters
差不多:
/**
* Obtain the return type of a function type
*/
type ReturnType<T extends (...args: any) => any> = T extends (...args: any) => infer R ? R : any;
结语
今天我们暂且介绍这么多常用的工具类,实际上,在Typescript
当中,提供的工具类远不止于这些,大家有兴趣可以自行查阅相关资料学习了解。个人的想法是,我们只需掌握上述常用的工具类即可,其他更多的工具,我们只需要有一个印象,等实际使用时再查阅相关文档即可,无需死记硬背。希望这些常用工具能让你再编写自己的类型系统时如虎添翼,更加快捷高效的定义类型,为项目的高质量稳定迭代保驾护航。
转载自:https://juejin.cn/post/7327570230774857762