let title: {}
title = {} // correct
title = [] // correct
title = 123 // correct
let content: object
content = {} // correct
content = [] // correct
content = 123 // error: Type '123' is not assignable to type 'object'.ts(2322)
title 不是一个空对象吗?为什么可以被赋值成一个数字或者数组?content = 123 的报错似乎很明显,但是为什么 content = [] 又不会报错?
了解了这几个重要的差异之后,我们再回到 type alias 和 interface 的使用场景。一般来讲,使用哪种更多的是个人偏好,不过 type alias 似乎比 interface 要简洁通用一些 (type alias 支持类型表达式比如条件判断)。而如果你准备编写一个公共库,可能还需要仔细考虑库中定义的类型是否允许使用者扩展 (declaration merging)。
有哪些地方可以定义泛型参数?
回忆一下我们用到泛型最多的情况应该是在函数中:
function arrayify<T>(data: T): T[] {
return [data]
}
我们调用 arrayify 时传入了不同类型的参数,但是并没有显示指定泛型参数 T 的具体类型,这是因为 TypeScript 会根据函数参数来推断泛型的具体类型。
再来看看上面的 Arrayify:
type Arrayify = <T>(data: T) => T[]
type StringArr = Arrayify // correct
上面的代码很好理解,只是把 Arrayify 赋值给了另一个 type alias StringArr,这俩是等价的。我们再试试当 Arrayify 的泛型参数是声明在 type alias 后面的情况:
type Arrayify<T> = (data: T) => T[]
type StringArr = Arrayify // error: Generic type 'Arrayify' requires 1 type argument(s).(2314)
type NumberArr = Arrayify<number> // correct
TypeScript 并不允许我们在不提供泛型参数值的情况下直接把 Arrayify 赋值给 StringArr。回想一下上一个问题中我有提到过:“简单来讲,泛型定义的位置决定了它涵盖的作用域”,注意只是“简单来讲”,还没说完呢,实际上泛型定义的位置不仅决定了它涵盖的作用域,还决定了 TypeScript 什么时候会给泛型参数赋予具体类型。如果泛型参数声明在调用签名前,表示函数调用的时候会决定好泛型的具体类型 (我们可以手动指定,也可以让 TypeScript 根据函数参数来推断)。而如果是直接定义在 type alias 后面的泛型参数,那么在使用这个 type alias 时我们必须要手动指定明确的具体类型,因为这种情况 TypeScript 无法帮我们推断出泛型的具体类型。
关于 Arrayify<number> 还有一点要说的是,可以把 Arrayify 理解成一个“函数”,Arrayify<number> 理解成函数调用。每次“调用” Arrayify 时,会生成一个新的、泛型参数绑定到我们传入类型的 type alias。
为什么在构造 Promise 实例时需要明确指定泛型参数的具体类型?
我们先来构造一个简单的 Promise 实例:
let promise = new Promise(resolve => resolve(45))
promise.then(value => value * 2) // error: Object is of type 'unknown'.(2571)
上面这段代码没有类型信息,只是一段普通的 JavaScript 代码,但是在 TypeScript 环境中执行到 value * 2 时却报错了。
什么是类型 (Type)?
别误会,我并不是要介绍 TypeScript 中的一些基础语法或者基础类型,这些我相信你在学习 TypeScript 第一天的时候就已经烂熟于胸了。比如我们都知道字符串的类型是
string
、 在该用到数字的时候应该限制类型为number
、===
运算符的返回值是boolean
,对类型的理解和使用似乎已经成为了一种本能,但是我还是想给什么是类型下个统一的定义:类型就是一系列值以及你可以对这些值做的事情。比如我们刚刚说的number
类型其实就是所有的数字加上所有你能对数字做的操作,比如+, -, *, /
操作符以及能对数字类型调用的方法。这里可能有点咬文嚼字了,但是通过对类型下一个定义,或者说用一种接近本质的角度来看待类型,能让人在每次看到它们时,有一种更深刻的理解,至少对我是这样。
{}
,object
,Object
有什么区别?首先,这三种类型都表示你的值是一个没有任何自定义属性的对象,只从
Object.prototype
继承了基本的方法。意味着 TypeScript 会有以下限制:另一方面,如果你之前不了解
{}
,object
,Object
分别代表哪些值 (回想一下我们上面对类型的定义),下面这段代码可能会让你感觉相当困惑:title
不是一个空对象吗?为什么可以被赋值成一个数字或者数组?content = 123
的报错似乎很明显,但是为什么content = []
又不会报错?我们知道 JavaScript 中有 7 种原始类型 (primitive type):
string
boolean
number
bigint
symbol
null
undefined
除此之外的类型都称为非原始类型 (non-primitive type),而
object
(TypeScript v2.2 新加入的类型)就是用来表示这些非原始类型的。也就是说,如果一个变量的值是object
类型,那么它可以是任何非原始类型值,比如上面的空对象和空数组,但是不能是原始类型值,比如123
。{}
类型不仅包含非原始类型,还包含除null | undefined
之外的其他原始类型,这也是为什么把123
和[]
赋值给{}
类型都不会报错。清楚了每个类型所包含的值的范围,也就很好理解上面的代码为什么会有这样的差异了。至于Object
的话,在行为上跟{}
基本上是一样的。新增的
object
类型在某些情况下是有用的,比如用来限定Object.create
方法的参数类型:什么时候用 type alias?什么时候用 Interface?
type alias 和 interface 在很多时候都可以相互替换使用,具体什么情况该用哪一个并没有强制的要求。相比直接提供一些使用的建议,我觉得把两者主要的差异点先列出来也许更有必要:
string
,number
) 和类型表达式,interface 只能是对象类型 (shape);注意:上面有提到一个 shape 类型,其实就是非原始类型
object
。很多人会误以为 interface 只能继承其他 interface、class 只能implements
interface,但实际上可以 extends 或者 implements 其他任何 shape 类型。了解了这几个重要的差异之后,我们再回到 type alias 和 interface 的使用场景。一般来讲,使用哪种更多的是个人偏好,不过 type alias 似乎比 interface 要简洁通用一些 (type alias 支持类型表达式比如条件判断)。而如果你准备编写一个公共库,可能还需要仔细考虑库中定义的类型是否允许使用者扩展 (declaration merging)。
有哪些地方可以定义泛型参数?
回忆一下我们用到泛型最多的情况应该是在函数中:
这算的上是最简单的使用泛型的场景了。我们除了可以在函数里使用泛型参数来设置约定,还有以下几个场景可以用到泛型:
class Arrayify<T> {}
type Arrayify = <T>(data: T) => T[]
type Arrayify<T> = (data: T) => T[]
interface Arrayify { <T>(data: T): T[] }
interface Arrayify<T> { (data: T): T[] }
例 1 很简单,class 在 JavaScript 中本质上还是函数,所以泛型的使用跟普通函数一致。2、 3 一眼看上去非常类似,只是泛型定义的位置不同。2 中的泛型参数定义在调用签名 (call signature) 前面,而 3 的泛型参数紧跟在 type alias 后面。就这个
Arrayify
例子而言,虽然泛型位置不同,但是 2 跟 3 的效果是一样的,那么这两种定义方式有什么区别?简单来讲,泛型定义的位置决定了它涵盖的作用域。再举个例子:
这个例子应该是非常清晰的,定义在调用签名签名的泛型参数只能用在单个调用签名中,而定义在 type alias 后面的泛型参数可以用在整个 type 中。
例 4、5 没有细讲,是因为 interface 在使用泛型的情况下跟 type alias 是类似的,大家自行脑补就好。
使用泛型时为什么有的时候我不用提供具体的类型,而有时候必须要提供?
以上面的
arrayify
函数举例:我们调用
arrayify
时传入了不同类型的参数,但是并没有显示指定泛型参数T
的具体类型,这是因为 TypeScript 会根据函数参数来推断泛型的具体类型。再来看看上面的
Arrayify
:上面的代码很好理解,只是把
Arrayify
赋值给了另一个 type aliasStringArr
,这俩是等价的。我们再试试当Arrayify
的泛型参数是声明在 type alias 后面的情况:TypeScript 并不允许我们在不提供泛型参数值的情况下直接把
Arrayify
赋值给StringArr
。回想一下上一个问题中我有提到过:“简单来讲,泛型定义的位置决定了它涵盖的作用域”,注意只是“简单来讲”,还没说完呢,实际上泛型定义的位置不仅决定了它涵盖的作用域,还决定了 TypeScript 什么时候会给泛型参数赋予具体类型。如果泛型参数声明在调用签名前,表示函数调用的时候会决定好泛型的具体类型 (我们可以手动指定,也可以让 TypeScript 根据函数参数来推断)。而如果是直接定义在 type alias 后面的泛型参数,那么在使用这个 type alias 时我们必须要手动指定明确的具体类型,因为这种情况 TypeScript 无法帮我们推断出泛型的具体类型。关于
Arrayify<number>
还有一点要说的是,可以把Arrayify
理解成一个“函数”,Arrayify<number>
理解成函数调用。每次“调用”Arrayify
时,会生成一个新的、泛型参数绑定到我们传入类型的 type alias。为什么在构造 Promise 实例时需要明确指定泛型参数的具体类型?
我们先来构造一个简单的
Promise
实例:上面这段代码没有类型信息,只是一段普通的 JavaScript 代码,但是在 TypeScript 环境中执行到
value * 2
时却报错了。要知道具体原因的话我们得看看
Promise
构造函数本身具体的定义是怎样的:上面的类型定义来自 TypeScript 内置的
lib.es2015.promise.d.ts
文件,我们省略了一些实例方法,只关注构造函数。可以看到Promise
构造函数有一个泛型参数,回忆下在上一问题中我们说过:TypeScript 通过函数参数来推断其泛型参数,那么new Promise(resolve => resolve(45))
显然是不能提供足够的信息来帮助 TypeScript 推断出泛型参数具体类型的,因为resolve(45)
是函数体中的表达式。既然 TypeScript 无法从参数里推断出泛型的具体类型,我们在
new
表达式中也没有为泛型指定具体类型,那么T
的具体类型应该是什么?在 TypeScript v3.7.2 中,T
绑定到了unknown
, 而我测试在 v3.4.4 中T
则被绑定到了{}
。无论是unknown
还是{}
,TypeScript 都不会让我们执行value * 2
,因为*
操作数只能是数字类型的 (实际上可以是any
,number
,bigint
或者 enum type)。所以我们必须给
Promise
中的T
明确指定一个具体类型:如何理解泛型参数运算符
extends
?顾名思义,extends 表示继承,出现最多的场景也是类的继承,比如
class Dog extends Animal
。在 TypeScript 中,extends
关键字不仅可以用来继承类,还能用来操作泛型参数:那这里怎么理解这个
extends
比较合适?T
继承UserProp
?似乎有点不是很直观,其实这里指的是,T
必须是UserProp
本身或者其子类型,是对T
的一种限制 (constraint),有种类继承反过来的意思。因为
T
必须是UserProp
的子类型,那么形参prop
必须是name
或者gender
。d.ts 跟普通的 .ts 文件有什么区别?
.ts
文件不用多说,就是存储 TypeScript 常规代码的文件扩展名。那么d.ts
文件又是用来做什么的?其实是用来给 JavaScript 代码添加类型的。TypeScript 允许我们在.ts
里导入外部的 JavaScript 模块,但是 JavaScript 本身并没有 type 信息,意味着 TypeScript 没办法帮我们安全地校验模块的使用方式,所以需要有一种方式来给 JavaScript 代码添加类型信息。为了搞清楚应该如何给 JavaScript 添加类型信息,我们可以利用 TypeScript 在编译.ts
文件的时候能够自动生成对应d.ts
文件的特性看看d.ts
文件的具体内容。假设我们要编译的模块是
util.ts
:在
tsconfig.json
中,开启编译选项declaration
,告诉 TypeScript 在将util.ts
编译成 JavaScript 代码时,自动生成d.ts
文件,里面包含对应的类型信息:在执行
tsc
之后,我们可以看到util.ts
同级目录中会多出两个文件:util.js
、util.d.ts
。util.js
是util.ts
编译后的 JavaScript 代码,而util.d.ts
就是util.js
对应的类型信息。打开
util.d.ts
可以看到里面的内容如下:语法看起来跟普通的 TypeScript 代码一样,不过只有类型,没有值。可以理解为
util.d.ts
就是util.ts
的代码减去值,只保留了类型信息。utils.ts
中除了greeting
函数外还声明了一个模块内的局部变量greetingLevel
,但是这个变量并没有出现在util.d.ts
中。这是因为util.ts
是模块模式 (module mode),所以对应的d.ts
只会包含模块导入导出的类型信息,而局部变量的类型会被省略。这很好理解,毕竟模块的局部变量无法在模块外访问,所以自然也没有必要把局部变量的类型信息包含在d.ts
中。我们还注意到export
后面出现了一个新的关键字declare
。declare
用来表示一个断言:在相应的 JavaScript 模块中,一定导出了一个函数greeting
,它的类型是(name: string) => string
。注意只有在编写类型信息时才会用到declare
关键字。这里总结一下,
d.ts
是用来给 JavaScript 添加类型信息的,所以我们能够在 TypeScript 项目中安全的使用 JavaScript 模块。如果项目都是 TypeScript 代码,那么基本上不会用到d.ts
文件,因为.ts
文件本身就包含类型。但是如果我们某些依赖的模块是用 JavaScript 写的,并且没有对应的d.ts
,DefinitelyTyped 中也没有第三方贡献的 type 模块,这个时候可能需要我们自己在项目中新建一个d.ts
文件,为这些 JavaScript 模块增加相应的类型。什么是三斜杠指令 (Triple slash directive)?什么时候我该用它们?
三斜杠指令只是一种特殊的 JavaScript 注释,以
///
开头,紧接着一个 XML 标签,比如<reference lib="dom" />
。三斜杠指令一般用来为某一文件制定特殊的编译选项,或者指示某一文件依赖其他文件。还有一类是跟 AMD 模块相关的指令,比如<amd-module name="CustomModuleName" />
。那么你什么时候会用它们?你不用。是的,你几乎永远都不会用到三斜杠指令,所以这里也不再讨论每个指令的具体含义。反过来讲,如果你发现自己好像必须要用到某个指令,最好想想目前的编码模式是不是有问题。
总结
TypeScript 出现的目的是给 JavaScript 附加一套完整的类型系统,但是早期的 JavaScript 并没有完备的规范,所以对一些问题衍生出了很多不同的解决方案,比如模块系统。TypeScript 为了兼容现有的 JavaScript 生态,使自身也增加了很多除普通类型系统之外的东西,例如兼容各类模块规范、给 JavaScript 代码编写类型、扩展模块类型信息等等。正是因为 TypeScript 在语法和使用方面细节很多,官方文档难免会有些遗漏,所以希望这篇文章能做到一个不错的补充。