对于从JavaScript转来的TypeScript的初学者来说,一开始无脑用一个新的interface
规定一切的方式确实很过瘾。但是,当不得不尝试提高函数或组件的通用性时,使用泛型成了必须的选择。当尝试阅读一些用TypeScript开发的库的源码时,可能会被各种尖括号包裹的复杂泛型运算搞的晕头转向。本文的目的在于对泛型中的常见关键词和用法进行介绍,并且尝试用一定数量的例子来方便理解泛型。阅读本文之前,希望你已经阅读过TypeScript官方文档。
在TypeScript
中,type
和interface
关键字中在多数情况下功能是相同的,因此在本文的例子中不会刻意进行区分。首先定义下面一个类型,便于后面的使用。
type User = {
name: string;
age: number;
}
关键字
keyof 关键字
keyof
的功能较为简单,对于一个object类型,得到这个类型的所有属性名构成的联合类型。
type TA = keyof User
// 'name' | 'age'
在这个例子中,我们得到一个新的类型TA
,这个类型的实例必须为'name' 'age'
这两个字符串之一,这样的单一字符串也是一种类型,属于字面量类型。
typeof 关键字
typeof
是针对某一个类型的实例来讲的,我们将得到这个实例的类型。
const fn = () => ({name: 'blasius', age: 18});
type TB = typeof fn;
// () => {name: string, age: number}
这里的类型TB
,是一个新的类型,写作() => {name: string, age: number}
,这是一个函数类型。这个类型的函数的返回值是一个新的类型,写作{name: string, age: number}
,然而,这个类型暂时还没有特定的名称。假如我们想把这个返回值的类型提取出来,可以使用ReturnType
这个工具类型,本文后面会具体介绍。
extends 关键字
extends
关键字在类型运算中的作用,不是继承或者扩展,而是判断一个类型是否可以被赋值给另一个类型。如上面的类型TB
,是一个函数,因此这个类型是可以赋值给类型Function
。
extends
有时被用来进行类型约束。考虑下面的例子:
function logLength<T>(arg: T) {
console.log(arg.length);
// Property 'length' does not exist on type 'T'.
}
此时我们无法保证类型T
一定包含length
这个属性,因此会出现错误。考虑进行如下修改:
// 定义一个类型ILengthy
interface ILengthy {
length: number;
}
function logLength2<T extends ILengthy>(arg: T) {
console.log(arg.length);
}
对于函数logLength2
来说,我们规定了类型T
必须是ILengthy
可赋值的类型,也就是说,T
必须包含类型为number
的属性length
,这样一来,我们成功对函数的参数进行了约束。
extends
的另一种用法,是在类型运算中进行条件运算,具体用法将会在后面的工具类型中进行介绍。
infer 关键字
infer
一般用于类型提取,其作用类似于在类型运算的过程中声明了一个变量。考虑下面的例子:
type UserPromise = Promise<User>
这个类型表示一个返回值类型为User
的Promise
类型。我们想把User
这个类型从这个已知的函数中提取出来,应当使用infer
关键字:
type UnPromisify<T> = T extends Promise<infer V> ? V : never;
type InferedUser = UnPromisify<UserPromise>
// { name: number; age: string; }
考虑这个例子中的UnPromisify
类型,这个类型接受一个泛型T
。接下来通过extends
关键字进行判断,如果T
的类型形如Promise<V>
,那么就把这个V
提取出来。为了更好的理解infer
的作用,在这个例子中,可以认为infer
声明了一个变量V
。这个例子,我们结合extedns
和infer
实现了类型提取。
工具类型
所谓工具类型,形如type ToolType<T, ....> = R
。为了便于理解我们可以将其看做是用type
关键字定义的一个封装好的针对类型的“函数”。传给工具类型的,被包裹在尖括号之内的泛型T
,就是函数的参数。等号右边的,就是这个“函数”的返回值。
有了所谓”函数“,也必须有”变量“。对于初学者来说,对于泛型感到不理解的主要困境在于:没有区分什么时候是类型的”函数“,什么时候是类型的”变量“。
上面提到的UnPromisify<T>
,就是这样一个类型的”函数“,因为尖括号中的T
是不确定的,因此称为泛型。相对的,上面提到的UnPromisify<UserPromise>
,则是这个“函数”的执行结果,可以理解为类型的”变量“,因为尖括号中的UserPromise
是一个确定的类型,{ name: number; age: string; }
就是这个具体的结果的值。
下面介绍几个常用的工具类型,这几个“函数”已经作为标准存在于TypeScript
中,分析这几个“函数”的具体实现,有利于我们更好地理解泛型。
Partial<T>
、Required<T>
、Readonly<T>
、Mutable<T>
Partial<T>
这个类型“函数”的作用,在于给定一个输入的object类型T
,返回一个新的object类型,这个object类型的每一个属性都是可选的。
我们可以用基本的关键字来用自己的方式实现这个工具类型:
type MyPartial<T> = {
[K in keyof T]?: T[K]
}
type PartialUser = MyPartial<User>
// {name?: string, age?: number}
type TUserKeys = keyof User
// 'name' | 'age'
type TName = User['name']
// string
type TAge = User['age']
// number
type TUserValue = User[TUserKeys]
// string | number
上面的例子中,MyPartial<T>
是工具类型本身,PartialUser
是MyPartial<T>
传入了"参数"User
经过运算后的结果,因此是工具类型使用的实例。下面我们来逐步理解这个例子:
keyof T
代表类型T
的所有键构成的联合类型,等同于TUserKeys
K in keyof T
代表K
必须是这个联合类型中的一个- 有了具体的键,参考
TName
和TAge
的结果,就可以用T[K]
取出这个键对应的值的类型 - 至于中括号,这是
TypeScript
中的索引签名的类型 综上,MyPartial<T>
这个"函数"的”返回“值是一个新的object类型,这个类型的键和键的类型都和”输入参数“T
相同且一一对应,只不过每个键的后面都多了一个问号?
用来表示这个键可选罢了。
如果能理解Partial<T>
的实现,那么Required<T>
、Readonly<T>
和Mutable<T>
的实现都是类似的。都是只不过是把?
换成了readonly
或者-
用来表示不同的含义罢了。下面是这些工具类型的具体实现:
type MyRequired<T> = {
[K in keyof T]-?: T[K]
}
type MyReadonly<T> = {
readonly [K in keyof T]: T[K]
}
type MyMutable<T> = {
-readonly [K in keyof T]: T[K]
}
Requiered<T>
表示根据T
得到新的类型,这个类型的每个键值都为必需。
Readonly<T>
表示由T
得到新的类型,这个类型的每个键的值都为只读的。
Mutable<T>
表示同样由T
得到新的类型,这个类型的每个键的值为可写的。
Record<K, T>
、Pick<T, K>
工具类型Record<K, T>
的实现:
type MyRecord<K extends keyof any, T> = {
[P in K]: T
}
type TKeyofAny = keyof any
// string | number | symbol
type TKeys = 'a' | 'b' | 0
type TKeysUser = MyRecord<TKeys, User>
// {a: User, b: User, 0: User}
Record<K, T>
接受两个类型作为”参数“,其中第一个参数K
是一个任意字符串、数字或Symbol的联合类型,第二个“参数”T
可以为任意类型。最终得到一个由K
中每个值作为键,值类型为T
的新的object类型。
类似的,Pick<T, K>
的实现:
type MyPick<T, K extends keyof T> = {
[P in K]: T[P]
}
type TNameKey = 'name'
type TUserName = MyPick<User, TNameKey>
// {name: string}
Pick
的功能很简单,从给定的类型T
中pick出特定的键和键类型,构成新的类型,另一个”参数“K
类型必须是keyof T
中的若干项构成的联合类型。
Exclude<T, U>
、Extract<T, U>
、NonNullable<T>
这三个工具类型的实现是类似的,都使用了extends
的基本用法来对联合类型进行条件性的选取, 已Exclude<T, U>
为例,若T
能够赋值给U
,则返回never
,否则返回T
本身,因此最终得到联合类型中存在于T
中但不存在于U
中的项:
type MyExclude<T, U> = T extends U ? never : T
type MyExtract<T, U> = T extends U ? T : never
type MyNonNullable<T> = T extends null | undefined ? never : T
Exclude<T, U>
和Extract<T, U>
通常是针对联合类型来使用的,两者的逻辑恰好相反。例如:
type TC = 'a' | 'b' | 'c'
type TD = 'a' | 'c' | 'e'
type TE = MyExclude<TC, TD>
// 'b'
type TF = MyExtract<TC, TD>
// 'a' | 'c'
Omit<T, K>
这个类型“函数”接受两个“参数”T
和K
,功能和Pick<T, K>
恰好相反,即从给定的类型T
中排除(exclude)掉特定的键和键类型,得到新的类型。因此,可以用Pick
配合Exclude
来实现。
type MyOmit<T, K> = Pick<T, Exclude<keyof T, K>>
type OmitUser = MyOmit<User, 'age'>
// { name: string }
思考OmitUser
的运算过程:
1、得到keyof User
为'name' | 'age'
2、从'name' | 'age'
中排除掉'age'
,得到剩下的'name'
3、Pick<User, 'name'>
,得到剩下的name
,成为一个新的类型
芜湖,一切都很顺理成章。
当不希望使用已有的Pick<T, K>
工具类型时,Omit<T, K>
还可以有另一种实现方式,观察其结构,可以发现Pick<T, K>
的影子。
type MyOmit2<T, K> = {
[P in MyExclude<keyof T, K>]: T[P];
}
构造函数类型和InstanceType<T>
形如new (args: any) => any
类型的函数,被称为构造函数类型。
下面的工具类型InstanceType
,用于取得构造函数的返回的实例的类型。
type MyInstanceType<T extends new (...args: any) => any> = T extends new (...args: any) => infer R ? R : any;
乍一看这个表达式十分复杂,但是主体结构仅仅是一个前面见过的extends
表达式而已:
图中红色放方框中代表构造函数类型。绿色方框中用infer
关键字声明了一个新的“类型变量”R
,若T
为构造函数类型,则可以得到该函数的返回实例的类型。
ReturnType<T>
、Parameters<T>
ReturnType
工具类型用于提取泛型T
的返回值。
Parameters
工具类型用于提取泛型T
的参数。
type MyReturnType<T extends (...args: any) => any> = T extends (...args: any) => infer R ? R : never;
type MyParameters<T extends (...args: any) => any> = T extends (...args: infer P) => any ? P : never;
为了理解这两个工具类型的实现,只需同样要把握住infer
的位置和匿名的函数类型(...args: any) => any
这两个要点即可。
总结
从上面的例子可以看出,所谓泛型,完全可以理解成一个类型的”函数“,把握住尖括号中的输入参数,注意观察等号右边的”函数返回值“。值得注意的是,尖括号中的内容,如果是形如T
、K
这样的,那么就是“函数”本身,如果尖括号内是一个确定的类型,那么就成了“函数”的执行结果。
一些常用的用TypeScript
写成的包如Redux的源码中,充斥着众多的泛型定义。对于用JavaScript
写成的包如React,在使用时必须同时安装的包@types/react
,其主要内容也是大量的类型和泛型定义,了解泛型不但有助于理解这些包的用法,这些包的源码结构也可以一目了然。这些,就是理解泛型运算的意义之所在。