我们在之前的 重温 TypeScript 与 深入 TypeScript 的章节当中介绍了一些 TypeScript
的基本使用方式和一些进阶内容,本来是打算将泛型相关内容一同整理到其中的,但是梳理下来发现泛型涉及到的内容还是比较多的,所以就另起篇幅来单独介绍介绍泛型的相关内容,主要参考的是 Typescript Generics,在原文基础之上有所调整,主要是方便自己理解,想要了解更为详细的内容可以参考原文
下面就让我们从头开始看起,其中主要包括以下相关内容
- 泛型是什么
- 泛型接口
- 泛型类
- 泛型约束
- 泛型参数默认类型
- 泛型条件类型
- 泛型工具类型
- 使用泛型创建对象
泛型是什么
其实简单来说,设计泛型的关键目的是在成员之间提供有意义的约束,这些成员可以是类的实例成员、类的方法、函数参数和函数返回值等
也就是说,泛型允许我们同一个函数接受不同类型参数,相比于使用 any
类型,使用泛型来创建可复用的组件要更好,因为泛型会保留参数类型,下面我们来一步一步揭示泛型的作用,就从一个通用的 identity
函数开始看起,该函数接收一个参数并直接返回它
1 | function identity(value) { |
现在,我们将 identity
函数做适当的调整,以支持 TypeScript
的 Number
类型的参数
1 | function identity(value: Number): Number { |
这里 identity
的问题是我们将 Number
类型分配给参数和返回类型,使该函数仅可用于该原始类型,此时如果我们传入一个字符串类型的参数,编辑器会提示我们有错误存在,也就是说该函数并不是可扩展或通用的,很明显这并不是我们所希望的
我们确实可以把 Number
换成联合类型又或是 any
,但是我们失去了定义应该返回哪种类型的能力,并且在这个过程中使编译器失去了类型保护的作用,我们的目标是让 identity
函数可以适用于任何特定的类型,为了实现这个目标,我们可以使用泛型来解决这个问题,具体实现方式如下
1 | function identity<T>(value: T): T { |
看上去是不是很轻松,就像传递参数一样,我们传递了我们想要用于特定函数调用的类型,如下图所示
参考上面的图片可以发现,当我们调用 identity<Number>(1)
的时候,Number
类型就像参数 1
一样,它将在出现 T
的任何位置填充该类型,图中 <T>
内部的 T
被称为类型变量,它是我们希望传递给 identity
函数的类型占位符,同时它被分配给 value
参数用来代替它的类型(此时 T
充当的是类型,而不是特定的 Number
类型)
其中函数当中的 T
代表着 Type
,在定义泛型时通常用作第一个类型变量名称,但实际上 T
可以用任何有效名称代替,除了 T
之外,以下是常见泛型变量代表的意思
K
(Key
),表示对象中的键类型V
(Value
),表示对象中的值类型E
(Element
),表示元素类型
其实并不是只能定义一个类型变量,我们可以引入希望定义的任何数量的类型变量,比如我们引入一个新的类型变量 U
,用于扩展我们定义的 identity
函数
1 | function identity<T, U>(value: T, message: U): T { |
这里我们在使用的时候明确的指定了 T
和 U
是 Number
和 string
类型,并做为一个参数传给函数,使用了 <>
括起来而不是 ()
,但是另外一种更为普遍的做法是使用『类型推论』,我们可以完全省略尖括号,也就是说让编译器来根据我们传入的参数自动地来确定 T
或者 U
的类型,从而使代码更简洁
1 | function identity<T, U>(value: T, message: U): T { |
这一点我们可以也通过编辑器的代码提示功能来进行发现
1 | // function identity<18, number>(value: 18, message: number): 18 |
另外,我们还可以为泛型中的类型参数指定默认类型,因为当使用泛型时没有在代码中直接指定类型参数,从实际值参数中也无法推测出时,这个默认类型就会起作用
1 | function identity<T = string, U = number>(value: T, message: U): T { |
以上过程,我们可以参考下面这张动图,来直观地感受一下类型传递的过程
如你所见,该函数接收你传递给它的任何类型,使得我们可以为不同类型创建可重用的组件,现在我们再回过头来看一下我们的 identity
函数
1 | function identity<T, U>(value: T, message: U): T { |
相比之前定义的 identity
函数,新的 identity
函数增加了一个类型变量 U
,但是该函数的返回类型我们仍然使用 T
,如果我们想要返回两种类型的对象该怎么办呢?针对这个问题,我们有多种方案,其中一种就是使用元组,即为元组设置通用的类型
1 | function identity<T, U>(value: T, message: U): [T, U] { |
虽然使用元组可以解决上述的问题,但是有没有其它更好的解决方案呢?答案是有的,那就是我们可以使用『泛型接口』
泛型接口
为了解决上面提到的问题,首先让我们创建一个用于的 identity
函数通用 Identities
接口
1 | interface Identities<V, M> { |
在上述的 Identities
接口中,我们引入了类型变量 V
和 M
,来进一步说明有效的字母都可以用于表示类型变量,之后我们就可以将 Identities
接口作为 identity
函数的返回类型
1 | function identity<T, U>(value: T, message: U): Identities<T, U> { |
运行后可以发现,是可以正常运行的,当然泛型除了可以应用在函数和接口之外,它也可以应用在类中,下面我们就来看一下在类中如何使用泛型
泛型类
在类中使用泛型也很简单,我们只需要在类名后面,使用 <T, ...>
的语法定义任意多个类型变量,具体示例如下
1 | interface GenericInterface<U> { |
接下来我们以实例化 myNumberClass
为例,来分析一下其调用过程
- 在实例化
IdentityClass
对象时,我们传入Number
类型和构造函数参数值18
,之后在IdentityClass
类中,类型变量T
的值变成Number
类型 IdentityClass
类实现了GenericInterface<T>
,而此时T
表示Number
类型,因此等价于该类实现了GenericInterface<Number>
接口- 而对于
GenericInterface<U>
接口来说,类型变量U
也变成了Number
所以说,使用泛型类可确保在整个类中一致地使用指定的数据类型,通常在决定是否使用泛型时,我们有以下两个参考标准
- 当函数、接口或类将处理多种数据类型时
- 当函数、接口或类在多个地方使用该数据类型时
通常而言,但是随着项目的发展,组件的功能通常会被扩展,这种增加的可扩展性最终很可能会满足上述两个条件,在这种情况下引入泛型将比复制组件来满足一系列数据类型更干净,下面我们再来看看 Typescript
泛型提供的一些其他功能
泛型约束
有时我们可能希望限制每个类型变量接受的类型数量,而这就是泛型约束的作用,下面我们通过几个例子来了解一下泛型约束
确保属性存在
有时候,我们希望类型变量对应的类型上存在某些属性,在这种情况下,除非我们显式地将特定属性定义为类型变量,否则编译器不会知道它们的存在,一个很好的例子是在处理字符串或数组时,我们会假设 length
属性是可用的,还是以上面的示例为例,我们来简单的调整一下,尝试输出参数的长度
1 | function identity<T>(arg: T): T { |
在上面的示例当中,我们想访问 arg
的 length
属性,但是编译器并不能证明每种类型都有 length
属性,所以就报错了,在这种情况下,我们可以对泛型进行约束,只允许这个函数传入那些包含 length
属性的变量,这也被称为『泛型约束』
在这种情况下,编译器将不会知道 T
确实含有 length
属性,尤其是在可以将任何类型赋给类型变量 T
的情况下,所以我们需要做的就是让类型变量 extends
一个含有我们所需属性的接口
1 | interface Length { |
<T extends Length>
用于告诉编译器,我们支持已经实现 Length
接口的任何类型,之后当我们使用不含有 length
属性的对象作为参数调用 identity
函数时,TypeScript
都会提示我们相关的错误信息,也就是说,现在这个泛型函数已经被定义了约束,因此它不再是适用于任意类型,所以我们需要传入符合约束类型的值,当然具有 length
属性的对象也是可以的
1 | // ✅ |
此外,我们还可以使用 ,
号来分隔多种约束类型,比如 <T extends Length, Type2, Type3>
,而对于上述的 length
属性问题来说,我们也可以显式地将变量设置为数组类型,这样也可以解决该问题,具体方式如下
1 | function identity<T>(arg: T[]): T[] { |
另外,多个类型参数之间也是可以互相约束的,比如下面这个示例
1 | function copyFields<T extends U, U>(target: T, source: U): T { |
在上面的示例当中,我们使用了两个类型参数,其中要求 T
继承 U
,这样就保证了 U
上不会出现 T
中不存在的字段,其中 <T>source
的写法等同于 source as T
,其实就是把 source
断言成 T
类型
检查对象上的键是否存在
泛型约束的另一个常见的使用场景就是检查对象上的键是否存在,而这一点主要依靠的是 keyof
操作符,keyof
操作符是在 TypeScript 2.1
版本引入的,该操作符可以用于获取某种类型的所有键,其返回类型是联合类型
1 | interface Person { |
这里关于 keyof { [x: string]: Person }
的使用方式我们多提及一些,我们在之前的章节当中曾经提到过,在 TypeScript
中支持两种索引签名,数字索引和字符串索引
1 | interface StringArray { |
为了同时支持两种索引类型,就得要求数字索引的返回值必须是字符串索引返回值的子类,其中的原因就是当使用数值索引时,JavaScript
在执行索引操作时,会先把数值索引先转换为字符串索引,所以 keyof { [x: string]: Person }
的结果会返回 string | number
让我们在回到 keyof
操作符上,通过 keyof
操作符,我们就可以获取指定类型的所有键,这样一来我们就可以结合前面介绍的 extends
约束,即限制输入的属性名包含在 keyof
返回的联合类型中,具体的使用方式如下
1 | function getProperty<T, K extends keyof T>(obj: T, key: K): T[K] { |
在以上的 getProperty
函数中,我们通过 K extends keyof T
确保参数 key
一定是对象中含有的键,这样就不会发生运行时错误,这是一个类型安全的解决方案,与简单调用 let value = obj[key]
不同,下面我们来看一下如何使用 getProperty
函数
1 | enum Difficulty { |
在以上示例中,对于 getProperty(tsInfo, 'superset_of')
这个表达式,TypeScript
编译器会提示以下错误信息
1 | Argument of type '"superset_of"' is not assignable to parameter of type '"difficulty" | "name" | "supersetOf"'.(2345) |
很明显通过使用泛型约束,在编译阶段我们就可以提前发现错误,大大提高了程序的健壮性和稳定性,接下来我们来看一看泛型参数的默认类型
泛型参数默认类型
这个特性我们在上面也简单提及过,在 TypeScript 2.3
以后,我们可以为泛型中的类型参数指定默认类型,当使用泛型时没有在代码中直接指定类型参数,从实际值参数中也无法推断出类型时,这个默认类型就会起作用,泛型参数默认类型与普通函数默认值类似,对应的语法很简单,即 <T = Default Type>
,对应的使用示例如下
1 | interface A<T = string> { |
泛型参数的默认类型遵循以下规则
- 有默认类型的类型参数被认为是可选的
- 必选的类型参数不能在可选的类型参数后
- 如果类型参数有约束,类型参数的默认类型必须满足这个约束
- 当指定类型实参时,我们只需要指定必选类型参数的类型实参,未指定的类型参数会被解析为它们的默认类型
- 如果指定了默认类型,且类型推断无法选择一个候选类型,那么将使用默认类型作为推断结果
- 一个被现有类或接口合并的类或者接口的声明可以为现有类型参数引入默认类型,也可以引入新的类型参数,只要它指定了默认类型
泛型条件类型
在 TypeScript 2.8
中引入了条件类型,使得我们可以根据某些条件得到不同的类型,这里所说的条件是类型兼容性约束,尽管代码中使用了 extends
关键字,也不一定要强制满足继承关系,而是检查是否满足结构兼容性,条件类型会以一个条件表达式进行类型关系检测,从而在两种类型中选择其一
1 | T extends U ? X : Y |
以上表达式的意思是若 T
能够赋值给 U
,那么类型是 X
,否则为 Y
,在条件类型表达式中,我们通常还会结合 infer
关键字,实现类型抽取
1 | interface Dictionary<T = any> { |
在上面示例中,当类型 T
满足 T extends Dictionary
约束时,我们会使用 infer
关键字声明了一个类型变量 V
(关于 infer
我们下面会进行介绍),并返回该类型,否则返回 never
类型,这里关于 never
类型,我们多提及一点
在
TypeScript
中,never
类型表示的是那些永不存在的值的类型,例如never
类型是那些总是会抛出异常或根本就不会有返回值的函数表达式或箭头函数表达式的返回值类型,另外,需要注意的是,没有类型是never
的子类型或可以赋值给never
类型(除了never
本身之外),即使any
也不可以赋值给never
除了上述的应用外,利用条件类型和 infer
关键字,我们还可以方便地实现获取 Promise
对象的返回值类型
1 | async function stringPromise() { |
泛型工具类型
为了方便开发者,TypeScript
内置了一些常用的工具类型,比如 Partial
、Required
、Readonly
、Record
和 ReturnType
等,不过在详细展开之前我们先来了解一下几个比较常用的操作符
in
操作符
in
操作符可以用来遍历枚举类型
1 | type Keys = 'a' | 'b' | 'c' |
typeof
操作符
typeof
操作符可以用来获取一个变量声明或对象的类型
1 | interface Person { |
需要注意以下这种使用方式
1 | class Greeter { |
infer
操作符
infer
最早是出现在这个 PR 当中,表示在 extends
条件语句中待推断的类型变量
1 | type ParamType<T> = T extends (param: infer P) => any ? P : T |
在这个条件语句 T extends (param: infer P) => any ? P : T
中,infer P
表示待推断的函数参数,如果 T
能赋值给 (param: infer P) => any
,则结果是 (param: infer P) => any
类型中的参数 P
,否则返回为 T
1 | type ParamType<T> = T extends (param: infer P) => any ? P : T |
而在 2.8
版本中,TypeScript
也已经内置了一些与 infer
有关的映射类型,比如用于提取函数类型的返回值类型
1 | type ReturnType<T> = T extends (...args: any[]) => infer P ? P : any |
相比于之前的示例,ReturnType<T>
只是将 infer P
从参数位置移动到返回值位置,因此此时 P
即是表示待推断的返回值类型
1 | type Func = () => User |
另外还可以用于提取构造函数中参数(实例)类型,比如一个构造函数可以使用 new
来实例化,因此它的类型通常表示如下
1 | type Constructor = new (...args: any[]) => any |
当 infer
用于构造函数类型中,可用于参数位置 new (...args: infer P) => any
和返回值位置 new (...args: any[]) => infer P
,因此就内置如下两个映射类型
1 | // 获取参数类型 |
当然关于 infer
还有许多的『骚操作』,比如 tuple
转 union
,union
转 tuple
等等,这里就不详细展开了,可以参考 infer 的一些用例 和 union to tuple 这两个链接来了解更多
extends
操作符
这个我们之前提到过,有时候我们定义的泛型不想过于灵活或者说想继承某些类等,可以通过 extends
关键字添加泛型约束
1 | interface Lengthwise { |
现在这个泛型函数被定义了约束,因此它不再是适用于任意类型
1 | // => 类型 `number` 的参数不能赋给类型 `Lengthwise` 的参数 |
这时我们需要传入符合约束类型的值,必须包含必须的属性
1 | // ❌ |
Omit
操作符
有时候我们需要复用一个类型,但是又不需要此类型内的全部属性,因此需要剔除某些属性,这个方法在 React
中经常用到,当父组件通过 props
向下传递数据的时候,通常需要复用父组件的 props
类型,但是又需要剔除一些无用的类型
1 | interface User { |
下面我们就正式来看看之前提到的 TypeScript
当中内置的一些常用的工具类型
Partial
Partial<T>
的作用就是将某个类型里的属性全部变为可选项 ?
,定义如下
1 | /** |
在以上代码中,首先通过 keyof T
拿到 T
的所有属性名,然后使用 in
进行遍历,将值赋给 P
,最后通过 T[P]
取得相应的属性值,中间的 ?
号,用于将所有属性变为可选,比如下面这个示例
1 | interface Todo { |
在上面的 updateTodo
方法中,我们利用 Partial<T>
工具类型,定义 fieldsToUpdate
的类型为 Partial<Todo>
,即
1 | { |
Record
Record<K extends keyof any, T>
的作用是将 K
中所有的属性的值转化为 T
类型,定义如下
1 | /** |
一个示例
1 | interface PageInfo { |
Pick
Pick<T, K extends keyof T>
的作用是将某个类型中的子属性挑出来,变成包含这个类型部分属性的子类型
1 | /** |
一个示例
1 | interface Todo { |
另外一个示例
1 | interface Test { |
Exclude
Exclude<T, U>
的作用是将某个类型中属于另一个的类型移除掉,定义如下
1 | /** |
如果 T
能赋值给 U
类型的话,那么就会返回 never
类型,否则返回 T
类型,最终实现的效果就是将 T
中某些属于 U
的类型移除掉,下面是一个简单的示例
1 | type T0 = Exclude<'a' | 'b' | 'c', 'a'> // 'b' | 'c' |
ReturnType
ReturnType<T>
的作用是用于获取函数 T
的返回类型,定义如下
1 | /** |
几个简单的示例
1 | type T0 = ReturnType<() => string> // string |
在了解完了泛型工具类型以后,最后我们再来看看如何使用泛型来创建对象
使用泛型创建对象
有时,泛型类可能需要基于传入的泛型 T
来创建其类型相关的对象,比如
1 | class FirstClass { |
在以上代码中,我们定义了两个普通类和一个泛型类 GenericCreator<T>
,在通用的 GenericCreator
泛型类中,我们定义了一个名为 create
的成员方法,该方法会使用 new
关键字来调用传入的实际类型的构造函数来创建对应的对象,但可惜的是,以上代码并不能正常运行,对于以上代码,在 TypeScript v3.9.2
编译器下会提示以下错误
1 | 'T' only refers to a type, but is being used as a value here. |
这个错误的意思是 T
类型仅指类型,但此处被用作值,那么如何解决这个问题呢?根据 TypeScript
文档,为了使通用类能够创建 T
类型的对象,我们需要通过其构造函数来引用 T
类型,对于上述问题,在介绍具体的解决方案前,我们先来介绍一下构造签名
构造签名
在 TypeScript
接口中,我们可以使用 new
关键字来描述一个构造函数
1 | interface Point { |
以上接口中的 new(x: number, y: number)
我们称之为『构造签名』,其语法如下
1 | ConstructSignature: new TypeParametersopt(ParameterListopt) TypeAnnotationopt |
在上述的构造签名中,TypeParametersopt
、ParameterListopt
和 TypeAnnotationopt
分别表示可选的类型参数、可选的参数列表和可选的类型注解,与该语法相对应的几种常见的使用形式如下
1 | new C |
在了解完构造签名以后,我们再来看看一个与之相关的概念,即『构造函数类型』
构造函数类型
在 TypeScript
语言规范中这样定义构造函数类型
1 | An object type containing one or more construct signatures is said to be a constructor type. |
通过规范中的描述信息,我们可以得出以下结论
- 包含一个或多个构造签名的对象类型被称为构造函数类型
- 构造函数类型可以使用构造函数类型字面量或包含构造签名的对象类型字面量来编写
那么什么是构造函数类型字面量呢?构造函数类型字面量是包含单个构造函数签名的对象类型的简写,具体来说,构造函数类型字面量的形式如下
1 | new < T1, T2, ... > ( p1, p2, ... ) => R |
该形式与以下对象类型字面量是等价的
1 | { new < T1, T2, ... > ( p1, p2, ... ) : R } |
下面我们来看一个实际的示例
1 | // 构造函数类型字面量 |
等价于以下对象类型字面量
1 | { |
构造函数类型的应用
在介绍构造函数类型的应用前,我们先来看个例子
1 | interface Point { |
对于以上的代码,TypeScript
编译器会提示以下错误信息
1 | Class 'Point2D' incorrectly implements interface 'Point'. |
我们先来尝试着解决这个错误,要解决这个问题,我们就需要把对前面定义的 Point
接口进行分离,即把接口的属性和构造函数类型进行分离
1 | interface Point { |
完成接口拆分之后,除了前面已经定义的 Point2D
类之外,我们又定义了一个 newPoint
工厂函数,该函数用于根据传入的 PointConstructor
类型的构造函数,来创建对应的 Point
对象
使用泛型创建对象
在了解完构造签名和构造函数类型之后,下面我们来开始解决开头部分 GenericCreator<T>
示例当中 T
被用作值的问题,首先我们需要重构一下 create
方法,具体如下所示
1 | class GenericCreator<T> { |
在以上代码中,我们重新定义了 create
成员方法,根据该方法的签名,我们可以知道该方法接收一个参数,其类型是构造函数类型,且该构造函数不包含任何参数,调用该构造函数后会返回类型 T
的实例
如果构造函数含有参数的话,比如包含一个 number
类型的参数时,我们可以这样定义 create
方法
1 | create<T>(c: { new(a: number): T }, num: number): T { |
更新完 GenericCreator
泛型类,我们就可以使用我们新的 create
方法来创建 FirstClass
和 SecondClass
类的实例
1 | class FirstClass { |
现在可以发现,程序已经可以正常运行