TypeScript 是一种由微软开发的自由和开源的编程语言,是 JavaScript 的一个超集,其为 JavaScript 引入了可选的静态类型,相比于 JavaScript 它的特点主要有以下三点:
- 类型检查:使我们可以在编译阶段发现问题而不是运行时
- 语言扩展:TypeScript 不仅仅包括了 ES6 及未来提案中的一些特性,还从其他语言借鉴了一些特性,比如接口和抽象类
- 工具属性:TypeScript 会编译生成 JavaScript 运行在浏览器及不同操作系统上,无其他运行时开销
那么为什么我们需要 TypeScript 帮我们引入静态类型呢?
静态类型语言可以在编译阶段确定所有变量的类型,而动态类型语言只能在执行阶段确定所有变量的类型,我们以相同功能的 Javascript 和 C++ 代码为例:
1 | // javascript |
1 | // C++ |
在上述示例中 a、b 都是类 C 的实例对象,add 函数返回 a、b 对象所有属性的和,对于 JavaScript 而言,属性结构图如下图所示:
- 在程序运行时动态计算属性偏移量
- 需要额外的空间存储属性名
- 所有对象的属性偏移量量信息各存一份
而对于C++而言:
- 编译阶段确定属性偏移量
- 偏移量访问代替属性名访问
- 属性偏移量信息共享
由此可以看到动态类型语言无论在时间还是空间上都有比较多的性能损耗,虽然实际上 V8 为了提升 JavaScript 运行时的性能做了很多优化,但是 TypeScript 更重要的价值在于将静态类型的编程思维引入了 JavaScript,让我们可以在编译阶段以一种完全不同的视角去看待我们的代码。
基本类型&语法
一、boolean类型
1 | let bool1: boolean = true |
二、number类型
1 | let num1: number = 123 |
三、string类型
1 | let str1: string = 'abc' |
我们可以通过直接声明或让 TypeScript 推导等多种方式声明 boolean / number / string 类型,需要注意的是通过 const 关键字声明会将类型收敛为具体的属性值。
四、symbol类型
1 | let s1 = Symbol('a'); // symbol |
这儿需要注意的是使用 const 声明的符号,TypeScript 会推导为 unique symbol 类型,在代码编辑器中显示为 type of yourVariablename,而不是 unique symbol。
五、any类型
在 TypeScript 中,任何类型都可以被归为 any 类型。这让 any 类型成为了类型系统的顶级类型(也被称作全局超级类型)。
1 | let notSure: any = 666; |
any 类型本质上是类型系统的一个逃逸舱。作为开发者,这给了我们很大的自由:TypeScript 允许我们对 any 类型的值执行任何操作,而无需事先执行任何形式的检查。比如:
1 | let value: any; |
在许多场景下,这太宽松了。使用 any 类型,可以很容易地编写类型正确但在运行时有问题的代码。如果我们使用 any 类型,就无法使用 TypeScript 提供的大量的保护机制。如果想让 TypeScript 在遇到隐式 any 类型报错我们可以在 tsconfig.json 中启用 noImplicitAny,为了解决 any 带来的问题,TypeScript 3.0 引入了 unknown 类型。
六、unknown类型
就像所有类型都可以赋值给 any,所有类型也都可以赋值给 unknown。这使得 unknown 成为 TypeScript 类型系统的另一种顶级类型。下面我们来看一下 unknown 类型的使用示例:
1 | let value: unknown; |
对 value 变量的所有赋值都被认为是类型正确的。但是,当我们尝试将类型为 unknown 的值赋值给其他类型的变量时会发生什么?
1 | let value: unknown; |
unknown 类型只能被赋值给 any 类型和 unknown 类型本身。直观地说,这是有道理的:只有能够保存任意类型值的容器才能保存 unknown 类型的值。毕竟我们不知道变量 value 中存储了什么类型的值。
现在让我们看看当我们尝试对类型为 unknown 的值执行操作时会发生什么。以下是我们在之前 any 章节看过的相同操作:
1 | let value: unknown; |
将 value 变量类型设置为 unknown 后,这些操作都不再被认为是类型正确的。通过将 any 类型改变为 unknown 类型,我们已将允许所有更改的默认设置,更改为禁止任何更改,因此我们执行操作时不能假定 unknown 类型的值为某种特定类型,必须先向 typeScript 证明一个值确实是某个类型,举个🌰:
1 | let a: unknown = 30 |
七、数组类型
1 | let list: number[] = [1, 2, 3]; |
TypeScript 支持两种注解数组类型的句法:T[]
和 Array<T>
,两者的作用和性能无异,可以根据个人喜好进行选择。
一般情况下数组应该保持同质,TypeScript 在类型推断时也会尽可能收窄,举个🌰:
1 | let a = [1, 2, 3] // number[] |
我们可以注意到使用 const 声明数组并不会导致 TypeScript 推导出更窄的数据类型,事实上所有的引用类型都不会被 const 声明收窄,这也与 const 声明的引用类型的属性还可以再修改有关。
在初始化空数组时,TypeScript 不知道数组中的元素类型,推导出类型为 any,向数组中添加元素后,TypeScript 开始拼凑数组的类型,当数组离开定义时所在的作用域后,TypeScript 将最终确定一个类型,不再扩张。举个🌰:
1 | function buildArray() { |
八、元组类型
众所周知,数组一般由同种类型的值组成,但有时我们需要在单个变量中存储不同类型的值,这时候我们就可以使用元组。在 JavaScript 中是没有元组的,元组是 TypeScript 中特有的类型,是数组的子类型,其工作方式类似于数组。
元组可用于定义具有有限数量的未命名属性的类型。每个属性都有一个关联的类型。使用元组时,必须提供每个属性的值,举个🌰:
1 | let tupleType: [string, boolean]; |
在上述示例中我们定义了一个名为 tupleType 的变量,它的类型是一个类型数组 [string, boolean]
,然后我们按照正确的类型依次初始化 tupleType 变量,并通过下标来访问其中元素,需要注意的是初始化元组时不仅仅需要保证每个属性类型的一致,同时必须提供每个属性的值,否则都会报错。
元组也支持剩余元素,即为元组定义最小长度,举个🌰:
1 | // 字符串列表,至少一个元素 |
常规数组是可变的,这也是多数时候我们想要的行为,不过有时我们希望数组不可变,修改之后得到新的数组而原数组没有变化,想要创建只读数组有如下方法注解类型:
1 | let a: readonly number[] = [1, 2, 3] |
上面示例中我们可以看出若想创建只读数组,要显式注解类型,若想更改只读数组,只能使用非变型方法,比如 .concat
和 .slice
,不能使用可变型方法,例如 .push
和 .splice
。
九、枚举类型
枚举类型:一组有名字的常量集合,使用枚举可以清晰地表达意图或创建一组有区别的用例。 TypeScript 支持数字的和基于字符串的枚举。
1、数字枚举
1 | enum Direction { |
默认情况下,NORTH 的初始值为0,其余的成员会从1开始自动增长。换句话说,Direction.SOUTH 的值为1,Direction.EAST 的值为2,Direction.WEST 的值为3。
以上的枚举示例经编译后,对应的 ES5 代码如下:
1 | ; |
从上面代码我们可以看到 TypeScript 通过反向映射实现了数字枚举,但是其他枚举却又有所不同。
2、字符串枚举
1 | enum Direction { |
对应的 ES5 代码如下:
1 | ; |
从上面示例中我们可以发现字符串枚举没有实现反向映射。
3、异构映射
异构枚举的成员值是数字和字符串的混合:
1 | enum Enum { |
对应的ES5代码如下:
1 | ; |
从示例我们可以发现异构枚举中的数字成员实现了反向映射,而字符串成员没有,但是异构枚举容易造成混淆,不推荐使用。
对于数字枚举和异构枚举,TypeScript 既允许通过值访问枚举,也允许通过键访问,不过这样极易导致问题,举个🌰:
1 | // 数字枚举 |
其实上述实例中部分枚举值并不存在例如 Direction1[10]
,但是 TypeScript 并没有阻止这种操作,为了避免这种不安全的访问操作,我们可以使用常量枚举。
4、常量枚举
除了数字枚举和字符串枚举之外,还有一种特殊的枚举——常量枚举。它是使用 const 关键字修饰的枚举,常量枚举会使用内联语法,不会为枚举类型编译生成任何 JavaScript,举个🌰:
1 | const enum Direction { |
对应的ES5代码如下:
1 | ; |
常量枚举对于上述不安全操作有了更好的处理:
1 | const enum Direction { |
5、枚举成员
枚举成员的值具有如下特性:
- 只读:枚举类型初始化以后不支持属性的修改,即枚举类型成员的属性都是只读属性
- 类型:枚举类型成员的值包括两种类型:常量类型(const number) 和 计算类型(computer number) ,常量类型包括没有初始值、引用已有枚举属性值和常量表达式三类,常量类型会在编译阶段编译出结果,以常量的形式出现在运行时环境,计算类型主要是一些非常量的表达式,这些表达式的值不会在编译阶段被计算而是保留到执行时阶段。
我们举个🌰:
1 | enum Char { |
对应的ES5的代码如下:
1 | var Char; |
十、void类型
在 JavaScript 中 void 是关键字,最关键的用途是获取 undefined:
1 | void 0; // undefined |
我们通过它可以有效避免 undefined 常量被重新赋值的情形,但在 TypeScript 中 void 类型表示没有任何返回:
1 | function func(): void { |
需要注意的是,声明一个 void 类型的变量没有什么作用,因为在严格模式下,它的值只能为 undefined:
1 | let unusable: void = undefined; |
十一、null和undefined类型
TypeScript 里,undefined 和 null 两者有各自的类型分别为 undefined 和 null。
1 | let u: undefined = undefined; |
需要注意的是在 TypeScript 规范中 undefined 和 null 是所有类型的子类型,所以当我们将 tsconfig.json 中的 strictNullChecks 选项设为 false 时我们可以将 null 和 undefined 赋值给其他类型的值,举个🌰:
1 | let num: number = 123; |
十二、never类型
never 类型表示的是那些永不存在的值的类型。 例如,never 类型是那些总是会抛出异常或根本就不会有返回值的函数表达式或箭头函数表达式的返回值类型。
1 | // 返回never的函数必须存在无法达到的终点 |
在 TypeScript 中,可以利用 never 类型的特性来实现全面性检查,具体示例如下:
1 | type Foo = string | number; |
注意在 else 分支里面,我们把收窄为 never 的 foo 赋值给一个显式声明的 never 变量。如果一切逻辑正确,那么这里应该能够编译通过。但是假如后来有一天你的同事修改了 Foo 的类型:
1 | type Foo = string | number | boolean; |
然而他忘记同时修改 controlFlowAnalysisWithNever 方法中的控制流程,这时候 else 分支的 foo 类型会被收窄为 boolean 类型,导致无法赋值给 never 类型,这时就会产生一个编译错误。通过这个方式,我们可以确保 controlFlowAnalysisWithNever 方法总是穷尽了 Foo 的所有可能类型。 通过这个示例,我们可以得出一个结论:使用 never 避免出现新增了联合类型没有对应的实现,目的就是写出类型绝对安全的代码。
十三、bigint类型
BigInt 是 ECMAScript 的一项提案,它在理论上允许我们建模任意大小的整数。但是 TypeScript 3.2 引入了一个新的原始类型 bigint,允许我们为 BigInit 数据类型进行类型检查,并支持在目标为 esnext 时输出 BigInit 字面量,我们可以通过调用 BigInt() 函数或书写 BigInt 字面量(或在整型数字字面量末尾添加 n)来获取 bigint:
1 | let foo: bigint = BigInt(100); // the BigInt function |
需要注意的是 bigint 和 number 之间无法混用,是完全不同的东西:
1 | let foo: number; |
还有一点要注意的是,对 bigint 使用 typeof 操作符返回一个新的字符串:“bigint”。因此,TypeScript 能够正确地使用 typeof 细化类型,举个🌰:
1 | function whatKindOfNumberIsIt(x: number | bigint) { |
十四、object、Object和{}类型
1、object类型
TypeScript 2.2 引入了被称为 object 类型的新类型,它用于表示非原始类型,在 JavaScript 中以下类型被视为原始类型:string、boolean、number、bigint、symbol、null 和 undefined。它的引入主要是因为随着 TypeScript 2.2 的发布,标准库的类型声明已经更新,例如 Object.create()
和 Object.setPrototypeOf()
方法都需要为它们的原型参数指定 object | null
类型:
1 | // node_modules/typescript/lib/lib.es5.d.ts |
将原始类型作为原型传递给 Object.setPrototypeOf()
或 Object.create()
将导致在运行时抛出类型错误。TypeScript 现在能够捕获这些错误,并在编译时提示相应的错误:
1 | const proto = {}; |
object 类型的另一个用例是作为 ES2015 的一部分引入的 WeakMap 数据结构。它的键必须是对象,不能是原始值。这个要求现在反映在类型定义中:
1 | interface WeakMap<K extends object, V> { |
2、Object类型
TypeScript 定义了另一个与新的 object 类型几乎同名的类型,那就是 Object 类型。该类型是所有 Object 类的实例的类型,实际上 Object 类由以下两个接口来定义:
- Object 接口定义了 Object.prototype 原型对象上的属性
- ObjectConstructor 接口定义了 Object 类的属性
(1) Object接口定义
1 | // node_modules/typescript/lib/lib.es5.d.ts |
(2) ObjectConstructor接口定义
1 | // node_modules/typescript/lib/lib.es5.d.ts |
Object 类的所有实例都继承了 Object 接口中的所有属性。我们可以看到,如果我们创建一个返回其参数的函数:
1 | function f(x: Object): { toString(): string } { |
当我们传入一个 Object 对象的实例时,它总是会满足该函数的返回类型 —— 即要求返回对象包含一个 toString()
方法。
有趣的是,类型 Object 包括原始值:
1 | function func1(x: Object) { } |
实际上这是因为 Object.prototype 的属性也可以通过原始值访问:
1 | 'semlinker'.hasOwnProperty === Object.prototype.hasOwnProperty // true |
相反,object 类型不包括原始值:
1 | function func2(x: object) { } |
需要注意的是,当对 Object 类型的变量进行赋值时,如果值对象属性名与 Object 接口中的属性冲突,则 TypeScript 编译器会提示相应的错误:
1 | const obj1: Object = { |
而对于 object 类型来说,TypeScript 编译器不会提示任何错误:
1 | const obj2: object = { |
另外在处理 object 类型和字符串索引对象类型的赋值操作时,也要特别注意。比如:
1 | let strictTypeHeaders: { [key: string]: string } = {}; |
在上述例子中,最后一行会出现编译错误,这是因为 { [key: string]: string }
类型相比 object 类型更加精确。而 header = strictTypeHeaders;
这一行却没有提示任何错误,是因为这两种类型都是非基本类型,object 类型比 { [key: string]: string }
类型更加通用。
3、{}类型
还有另一种类型与之非常相似,即空对象类型:{}。它描述了一个没有成员的对象。当你试图访问这样一个对象的任意属性时,TypeScript 会产生一个编译时错误:
1 | // Type {} |
但是,你仍然可以使用在 Object 类型上定义的所有属性和方法,这些属性和方法可通过 JavaScript 的原型链隐式地使用:
1 | // Type {} |
并且除 null 和 undefined 之外的任何类型都可以赋值给空对象类型,举个🌰:
1 | let danger = {} |
这极易造成误解,因此我们应该尽可能避免使用空对象类型,我们用以下示例说明空对象类型在日常写法中带来的快(tong)乐(ku):
在 JavaScript 中创建一个表示二维坐标点的对象很简单:
1 | const pt = {}; |
然而以上代码在 TypeScript 中,每个赋值语句都会产生错误:
1 | const pt = {}; |
这是因为第1行中的 pt 类型是根据它的值 {} 推断出来的,你只可以对已知的属性赋值。这个问题怎么解决呢?我们可能会先想到接口,比如这样子:
1 | interface Point { |
很可惜对于以上的方案,TypeScript 编译器仍会提示错误。那么这个问题该如何解决呢?其实我们可以直接通过对象字面量进行赋值:
1 | const pt = { |
而如果你需要一步一步地创建对象,你可以使用类型断言(as)来消除 TypeScript 的类型检查:
1 | const pt = {} as Point; |
但是更好的方法是声明变量的类型并一次性构建对象:
1 | const pt: Point = { |
另外在使用 Object.assign 方法合并多个对象的时候,你可能也会遇到以下问题:
1 | const pt = { x: 666, y: 888 }; |
这时候你可以使用对象展开运算符 … 来解决上述问题:
1 | const pt = { x: 666, y: 888 }; |
参考资料
极客时间《TypeScript开发实战》专栏
《深入理解TypeScript》
《TypeScript编程》