TypeScript教程
TypeScript教程
一、TypeScript 语言简介
1.1 概述
TypeScript(简称 TS)是微软公司开发的一种基于 JavaScript (简称 JS)语言的编程语言。
TypeScript 可以看成是 JavaScript 的超集(superset),即它继承了后者的全部语法,所有 JavaScript 脚本都可以当作 TypeScript 脚本(但是可能会报错),此外它再增加了一些自己的语法。
TypeScript 对 JavaScript 添加的最主要部分,就是一个独立的类型系统。
1.2 类型的概念
类型(type)指的是一组具有相同特征的值。如果两个值具有某种共同的特征,就可以说,它们属于同一种类型。
类型是人为添加的一种编程约束和用法提示。 主要目的是在软件开发过程中,为编译器和开发工具提供更多的验证和帮助,帮助提高代码质量,减少错误。
1 | function addOne(n:number) { |
上面示例中,函数addOne()有一个参数n,类型为数值(number),表示这个位置只能使用数值,传入其他类型的值就会报错。
TypeScript 是在开发阶段报错,这样有利于提早发现错误,避免使用时报错。另一方面,函数定义里面加入类型,具有提示作用,可以告诉开发者这个函数怎么用。
1.3 动态类型与静态类型
TypeScript 的主要功能是为 JavaScript 添加类型系统。
在语法上,JavaScript 属于动态类型语言。
TypeScript 引入了一个更强大、更严格的类型系统,属于静态类型语言。
1.4 静态类型的优点
(1)有利于代码的静态分析。
有了静态类型,不必运行代码,就可以确定变量的类型,从而推断代码有没有错误。这就叫做代码的静态分析。
(2)有利于发现错误。
由于每个值、每个变量、每个运算符都有严格的类型约束,TypeScript 就能轻松发现拼写错误、语义错误和方法调用错误,节省程序员的时间。
(3)更好的 IDE 支持,做到语法提示和自动补全。
IDE(集成开发环境,比如 VSCode)一般都会利用类型信息,提供语法提示功能(编辑器自动提示函数用法、参数等)和自动补全功能(只键入一部分的变量名或函数名,编辑器补全后面的部分)。
(4)提供了代码文档。
类型信息可以部分替代代码文档,解释应该如何使用这些代码,熟练的开发者往往只看类型,就能大致推断代码的作用。借助类型信息,很多工具能够直接生成文档。
(5)有助于代码重构。
修改他人的 JavaScript 代码,往往非常痛苦,项目越大越痛苦,因为不确定修改后是否会影响到其他部分的代码。
类型信息大大减轻了重构的成本。一般来说,只要函数或对象的参数和返回值保持类型不变,就能基本确定,重构后的代码也能正常运行。如果还有配套的单元测试,就完全可以放心重构。越是大型的、多人合作的项目,类型信息能够提供的帮助越大。
综上所述,TypeScript 有助于提高代码质量,保证代码安全,更适合用在大型的企业级项目。这就是为什么大量 JavaScript 项目转成 TypeScript 的原因。
1.5 静态类型的缺点
(1)丧失了动态类型的代码灵活性。
动态类型有非常高的灵活性,给予程序员很大的自由,静态类型将这些灵活性都剥夺了。
(2)增加了编程工作量。
有了类型之后,程序员不仅需要编写功能,还需要编写类型声明,确保类型正确。这增加了不少工作量,有时会显著拖长项目的开发时间。
(3)更高的学习成本。
类型系统通常比较复杂,要学习的东西更多,要求开发者付出更高的学习成本。
(4)引入了独立的编译步骤。
原生的 JavaScript 代码,可以直接在 JavaScript 引擎运行。添加类型系统以后,就多出了一个单独的编译步骤,检查类型是否正确,并将 TypeScript 代码转成JavaScript 代码,这样才能运行。
(5)兼容性问题。
TypeScript 依赖 JavaScript 生态,需要用到很多外部模块。但是,过去大部分JavaScript 项目都没有做 TypeScript 适配,虽然可以自己动手做适配,不过使用时难免还是会有一些兼容性问题。
二、TypeScript 基本用法
2.1 类型声明
TypeScript 代码最明显的特征,就是为 JavaScript 变量加上了类型声明。
1 | let foo:string; |
类型声明的写法,一律为在标识符后面添加“冒号 + 类型”。函数参数和返回值,也是这样来声明类型。
变量的值应该与声明的类型一致,如果不一致,TypeScript 就会报错。
另外,TypeScript 规定,变量只有赋值后才能使用,否则就会报错。
1 | let x:number; |
2.2 类型推断
类型声明并不是必需的,如果没有,TypeScript 会自己推断类型。
TypeScript 也可以推断函数的返回值。
1 | function toString(num:number) { |
TypeScript 的设计思想是,类型声明是可选的,你可以加,也可以不加。即使不加类型声明,依然是有效的 TypeScript 代码,只是这时不能保证TypeScript 会正确推断出类型。由于这个原因。所有 JavaScript 代码都是合法的TypeScript 代码。
这样设计还有一个好处,将以前的 JavaScript 项目改为 TypeScript 项目时,你可以逐步地为老代码添加类型,即使有些代码没有添加,也不会无法运行。
2.3 TypeScript 的编译
JavaScript 的运行环境(浏览器和 Node.js)不认识 TypeScript 代码。所以,TypeScript 项目要想运行,必须先转为 JavaScript 代码,这个代码转换的过程就叫做“编译”(compile)。
TypeScript 官方没有做运行环境,只提供编译器。编译时,会将类型声明和类型相关的代码全部删除,只留下能运行的 JavaScript 代码,并且不会改变 JavaScript 的运行结果。
因此,TypeScript 的类型检查只是编译时的类型检查,而不是运行时的类型检查。一旦代码编译为 JavaScript,运行时就不再检查类型了。
2.4 值与类型
“类型”是针对“值”的,可以视为是后者的一个元属性。每一个值在 TypeScript 里面都是有类型的。比如,3是一个值,它的类型是number。
TypeScript 代码只涉及类型,不涉及值。所有跟“值”相关的处理,都由 JavaScript 完成。
这一点务必牢记。TypeScript 项目里面,其实存在两种代码,一种是底层的“值代码”,另一种是上层的“类型代码”。前者使用 JavaScript 语法,后者使用 TypeScript的类型语法。
它们是可以分离的,TypeScript 的编译过程,实际上就是把“类型代码”全部拿掉,只保留“值代码”。
编写 TypeScript 项目时,不要混淆哪些是值代码,哪些是类型代码。
2.5 TypeScript Playground
最简单的 TypeScript 使用方法,就是使用官网的在线编译页面,叫做 TypeScriptPlayground。
只要打开这个网页,把 TypeScript 代码贴进文本框,它就会在当前页面自动编译出JavaScript 代码,还可以在浏览器执行编译产物。如果编译报错,它也会给出详细的报错信息。
这个页面还具有支持完整的 IDE 支持,可以自动语法提示。此外,它支持把代码片段和编译器设置保存成 URL,分享给他人。
2.6 tsc 编译器
TypeScript 官方提供的编译器叫做 tsc,可以将 TypeScript 脚本编译成 JavaScript脚本。本机想要编译 TypeScript 代码,必须安装 tsc。
根据约定,TypeScript 脚本文件使用.ts后缀名,JavaScript 脚本文件使用.js后缀名。tsc 的作用就是把.ts脚本转变成.js脚本。
2.7 ts-node 模块
ts-node 是一个非官方的 npm 模块,可以直接运行 TypeScript 代码。
三、any 类型,unknown 类型,never 类
3.1 any 类型
3.1.1 基本含义
any 类型表示没有任何限制,该类型的变量可以赋予任意类型的值。
1 | let x:any; |
变量类型一旦设为any,TypeScript 实际上会关闭这个变量的类型检查。即使有明显的类型错误,只要句法正确,都不会报错。
1 | let x:any = 'hello'; |
由于这个原因,应该尽量避免使用any类型,否则就失去了使用 TypeScript 的意义。
实际开发中,any类型主要适用以下两个场合。
(1)出于特殊原因,需要关闭某些变量的类型检查,就可以把该变量的类型设为any。
(2)为了适配以前老的 JavaScript 项目,让代码快速迁移到 TypeScript,可以把变量类型设为any。有些年代很久的大型 JavaScript 项目,尤其是别人的代码,很难为每一行适配正确的类型,这时你为那些类型复杂的变量加上any,TypeScript 编译时就不会报错。
总之,TypeScript 认为,只要开发者使用了any类型,就表示开发者想要自己来处理这些代码,所以就不对any类型进行任何限制,怎么使用都可以。
从集合论的角度看,any类型可以看成是所有其他类型的全集,包含了一切可能的类型。TypeScript 将这种类型称为“顶层类型”(top type),意为涵盖了所有下层。
3.1.2 类型推断问题
对于开发者没有指定类型、TypeScript 必须自己推断类型的那些变量,如果无法推断出类型,TypeScript 就会认为该变量的类型是any。
1 | function add(x, y) { |
对于那些类型不明显的变量,一定要显式声明类型,防止被推断为any。
TypeScript 提供了一个编译选项noImplicitAny,打开该选项,只要推断出any类型就会报错。
1 | $ tsc --noImplicitAny app.ts |
3.1.3 污染问题
any类型除了关闭类型检查,还有一个很大的问题,就是它会“污染”其他变量。它可以赋值给其他任何类型的变量(因为没有类型检查),导致其他变量出错。
1 | let x:any = 'hello'; |
3.2 unknown 类型
为了解决any类型“污染”其他变量的问题,TypeScript 3.0 引入了unknown类型。它与any含义相同,表示类型不确定,可能是任意类型,但是它的使用有一些限制,不像any那样自由,可以视为严格版的any。
unknown跟any的相似之处,在于所有类型的值都可以分配给unknown类型。
1 | let x:unknown; |
unknown类型跟any类型的不同之处在于,它不能直接使用。主要有以下几个限制。
- unknown类型的变量,不能直接赋值给其他类型的变量(除了any类型和unknown类型)。
1 | let v:unknown = 123; |
变量v是unknown类型,赋值给any和unknown以外类型的变量都会报错,这就避免了污染问题,从而克服了any类型的一大缺点。
- 不能直接调用unknown类型变量的方法和属性。
1 | let v1:unknown = { foo: 123 }; |
- unknown类型变量能够进行的运算是有限的,只能进行比较运算(运算符== 、===、!=、!==、||、&&、?)、取反运算(运算符!)、运算符和instanceof运算符这几种,其他运算都会报错。
正确使用unknown类型变量。
只有经过“类型缩小”,unknown类型变量才可以使用。所谓“类型缩小”,就是缩小unknown变量的类型范围,确保不会出错。
1 | let a:unknown = 1; |
unknown类型的变量a经过typeof运算以后,能够确定实际类型是number,就能用于加法运算了。这就是“类型缩小”,即将一个不确定的类型缩小为更明确的类型。
这样设计的目的是,只有明确unknown变量的实际类型,才允许使用它,防止像any那样可以随意乱用,“污染”其他变量。类型缩小以后再使用,就不会报错。
总之,unknown可以看作是更安全的any。一般来说,凡是需要设为any类型的地方,通常都应该优先考虑设为unknown类型。
在集合论上,unknown也可以视为所有其他类型(除了any)的全集,所以它和any一样,也属于 TypeScript 的顶层类型。
3.3 never 类型
TypeScript 还引入了“空类型”的概念,即该类型为空,不包含任何值。
由于不存在任何属于“空类型”的值,所以该类型被称为never,即不可能发生这样的值。
never类型的使用场景,主要是在一些类型运算之中,保证类型运算的完整性,另外,不可能返回值的函数,返回值的类型就可以写成never。
如果一个变量可能有多种类型(即联合类型),通常需要使用分支处理每一种类型。这时,处理所有可能的类型之后,剩余的情况就属于never类型。
1 | function fn(x:string|number) { |
never类型的一个重要特点是,可以赋值给任意其他类型。
1 | function f():never { |
为什么never类型可以赋值给任意其他类型呢?这也跟集合论有关,空集是任何集合的子集。TypeScript 就相应规定,任何类型都包含了never类型。因此,never类型是任何其他类型所共有的,TypeScript 把这种情况称为“底层类型”(bottomtype)。
总之,TypeScript 有两个“顶层类型”(any和unknown),但是“底层类型”只有never唯一一个。
四、TypeScript 的类型系统
4.1 基本类型
4.1.1 概述
JavaScript 语言(注意,不是 TypeScript)将值分成8种类型。
boolean
boolean类型只包含true和false两个布尔值。
string
string类型包含所有字符串。
number
number类型包含所有整数和浮点数。
bigint
bigint 类型包含所有的大整数。
bigint 与 number 类型不兼容。
注意,bigint 类型是 ES2020 标准引入的。如果使用这个类型,TypeScript 编译的目标 JavaScript 版本不能低于 ES2020(即编译参数target不低于es2020)。
symbol
symbol 类型包含所有的 Symbol 值。
object
object 类型包含了所有对象、数组和函数。
undefined
undefined 类型只包含一个值undefined,表示未定义(即还未给出定义,以后可能会有定义)。
null
null 类型也只包含一个值null,表示为空(即此处没有值)。
注意,如果没有声明类型的变量,被赋值为undefined或null,它们的类型会被推断为any。
TypeScript 继承了 JavaScript 的类型设计,以上8种类型可以看作 TypeScript 的基本类型。
注意,上面所有类型的名称都是小写字母,首字母大写的Number、String、Boolean等在 JavaScript 语言中都是内置对象,而不是类型名称。
另外,undefined 和 null 既可以作为值,也可以作为类型,取决于在哪里使用它们。
这8种基本类型是 TypeScript 类型系统的基础,复杂类型由它们组合而成。
4.2 包装对象类型
4.2.1 包装对象的概念
JavaScript 的8种类型之中,undefined和null其实是两个特殊值,object属于复合类型,剩下的五种属于原始类型(primitive value),代表最基本的、不可再分的值。
boolean
string
number
bigint
symbol
上面这五种原始类型的值,都有对应的包装对象(wrapper object)。所谓“包装对象”,指的是这些值在需要时,会自动产生的对象。
在 JavaScript 语言中,只有对象才有方法,原始类型的值本身没有方法。这行代码之所以可以运行,就是因为在调用方法时,字符串会自动转为包装对象,charAt()方法其实是定义在包装对象上。
这样的设计大大方便了字符串处理,省去了将原始类型的值手动转成对象实例的麻烦。
五种包装对象之中,symbol 类型和 bigint 类型无法直接获取它们的包装对象(即Symbol()和BigInt()不能作为构造函数使用),但是剩下三种可以。
Boolean()
String()
Number()
以上三个构造函数,执行后可以直接获取某个原始类型值的包装对象。
1 | const s = new String('hello'); |
注意,String()只有当作构造函数使用时(即带有new命令调用),才会返回包装对象。如果当作普通函数使用(不带有new命令),返回就是一个普通字符串。其他两个构造函数Number()和Boolean()也是如此。
4.2.2 包装对象类型与字面量类型
由于包装对象的存在,导致每一个原始类型的值都有包装对象和字面量两种情况。
1 | 'hello' // 字面量 |
为了区分这两种情况,TypeScript 对五种原始类型分别提供了大写和小写两种类型。
Boolean 和 boolean
String 和 string
Number 和 number
BigInt 和 bigint
Symbol 和 symbol
其中,大写类型同时包含包装对象和字面量两种情况,小写类型只包含字面量,不包含包装对象。
1 | const s1:String = 'hello'; // 正确 |
建议只使用小写类型,不使用大写类型。因为绝大部分使用原始类型的场合,都是使用字面量,不使用包装对象。而且,TypeScript 把很多内置方法的参数,定义成小写类型,使用大写类型会报错。
4.3 Object 类型与 object 类型
4.3.1 Object 类型
大写的Object类型代表 JavaScript 语言里面的广义对象。所有可以转成对象的值,是Object类型,这囊括了几乎所有的值。
1 | let obj:Object; |
事实上,除了undefined和null这两个值不能转为对象,其他任何值都可以赋值给Object类型。
另外,空对象{}是Object类型的简写形式,所以使用Object时常常用空对象代替。
4.3.2 object 类型
小写的object类型代表 JavaScript 里面的狭义对象,即可以用字面量表示的对象,只包含对象、数组和函数,不包括原始类型的值。
1 | let obj:object; |
大多数时候,我们使用对象类型,只希望包含真正的对象,不希望包含原始类型。所以,建议总是使用小写类型object,不使用大写类型Object。
注意,无论是大写的Object类型,还是小写的object类型,都只包含 JavaScript内置对象原生的属性和方法,用户自定义的属性和方法都不存在于这两个类型之中。
1 | const o1:Object = { foo: 0 }; |
4.4 undefined 和 null 的特殊性
undefined和null既是值,又是类型。
作为值,它们有一个特殊的地方:任何其他类型的变量都可以赋值为undefined或null。
1 | let age:number = 24; |
这并不是因为undefined和null包含在number类型里面,而是故意这样设计,任何类型的变量都可以赋值为undefined和null,以便跟 JavaScript 的行为保持一致。
JavaScript 的行为是,变量如果等于undefined就表示还没有赋值,如果等于null就表示值为空。所以,TypeScript 就允许了任何类型的变量都可以赋值为这两个值。
1 | const obj:object = undefined; |
上面示例中,变量obj等于undefined,编译不会报错。但是,实际执行时,调用obj.toString()就报错了,因为undefined不是对象,没有这个方法。
为了避免这种情况,及早发现错误,TypeScript 提供了一个编译选项strictNullChecks。只要打开这个选项,undefined和null就不能赋值给其他类型的变量(除了any类型和unknown类型)。
下面是 tsc 命令打开这个编译选项的例子。
1 | // tsc --strictNullChecks app.ts |
这个选项在配置文件tsconfig.json的写法如下。
1 | { |
打开strictNullChecks以后,undefined和null这两种值也不能互相赋值了。
总之,打开strictNullChecks以后,undefined和null只能赋值给自身,或者any类型和unknown类型的变量。
4.5 值类型
TypeScript 规定,单个值也是一种类型,称为“值类型”。
1 | let x:'hello'; |
上面示例中,变量x的类型是字符串hello,导致它只能赋值为这个字符串,赋值为其他字符串就会报错。
TypeScript 推断类型时,遇到const命令声明的变量,如果代码里面没有注明类型,就会推断该变量是值类型。
1 | // x 的类型是 "https" |
上面示例中,变量x是const命令声明的,TypeScript 就会推断它的类型是值https,而不是string类型。
这样推断是合理的,因为const命令声明的变量,一旦声明就不能改变,相当于常量。值类型就意味着不能赋为其他值。
注意,const命令声明的变量,如果赋值为对象,并不会推断为值类型。
1 | // x 的类型是 { foo: number } |
上面示例中,变量x没有被推断为值类型,而是推断属性foo的类型是number。这是因为 JavaScript 里面,const变量赋值为对象时,属性值是可以改变的。
值类型可能会出现一些很奇怪的报错。
1 | const x:5 = 4 + 1; // 报错 |
上面示例中,等号左侧的类型是数值5,等号右侧4 + 1的类型,TypeScript 推测为number。由于5是number的子类型,number是5的父类型,父类型不能赋值给子类型,所以报错了。
但是,反过来是可以的,子类型可以赋值给父类型。
如果一定要让子类型可以赋值为父类型的值,就要用到类型断言。
1 | const x:5 = (4 + 1) as 5; // 正确 |
在4 + 1后面加上as 5,就是告诉编译器,可以把4 + 1的类型视为值类型5,这样就不会报错了。
4.6 联合类型
联合类型(union types)指的是多个类型组成的一个新类型,使用符号|表示。
联合类型A|B表示,任何一个类型只要属于A或B,就属于联合类型A|B。
联合类型可以与值类型相结合,表示一个变量的值有若干种可能。
打开编译选项strictNullChecks后,其他类型的变量不能赋值为undefined或null。这时,如果某个变量确实可能包含空值,就可以采用联合类型的写法。
1 | let name:string|null; |
联合类型的第一个成员前面,也可以加上竖杠|,这样便于多行书写。
1 | let x: |
如果一个变量有多种类型,读取该变量时,往往需要进行“类型缩小”(type narrowing),区分该值到底属于哪一种类型,然后再进一步处理。
1 | function printId( |
解决方法就是对参数id做一下类型缩小,确定它的类型以后再进行处理。
1 | function printId( |
“类型缩小”是 TypeScript 处理联合类型的标准方法,凡是遇到可能为多种类型的场合,都需要先缩小类型,再进行处理。实际上,联合类型本身可以看成是一种“类型放大”(type widening),处理时就需要“类型缩小”(type narrowing)。
1 | function getPort( |
4.7 交叉类型
交叉类型(intersection types)指的多个类型组成的一个新类型,使用符号&表示。
交叉类型A&B表示,任何一个类型必须同时属于A和B,才属于交叉类型A&B,即交叉类型同时满足A和B的特征。
1 | let x:number&string; |
交叉类型的主要用途是表示对象的合成。
1 | let obj: |
交叉类型常常用来为对象类型添加新属性。
1 | type A = { foo: number }; |
4.8 type 命令
type命令用来定义一个类型的别名。
1 | type Age = number; |
别名可以让类型的名字变得更有意义,也能增加代码的可读性,还可以使复杂类型用起来更方便,便于以后修改变量的类型。
别名不允许重名。
别名的作用域是块级作用域。这意味着,代码块内部定义的别名,影响不到外部。
别名支持使用表达式,也可以在定义一个别名时,使用另一个别名,即别名允许嵌
套。
type命令属于类型相关的代码,编译成 JavaScript 的时候,会被全部删除。
4.9 typeof 运算符
avaScript 语言中,typeof 运算符是一个一元运算符,返回一个字符串,代表操作数的类型。
1 | typeof 'foo'; // 'string' |
注意,这时 typeof 的操作数是一个值。
JavaScript 里面,typeof运算符只可能返回八种结果,而且都是字符串。
1 | typeof undefined; // "undefined" |
TypeScript 将typeof运算符移植到了类型运算,它的操作数依然是一个值,但是返回的不是字符串,而是该值的 TypeScript 类型。
1 | const a = { x: 0 }; |
这种用法的typeof返回的是 TypeScript 类型,所以只能用在类型运算之中(即跟类型相关的代码之中),不能用在值运算。
也就是说,同一段代码可能存在两种typeof运算符,一种用在值相关的 JavaScript代码部分,另一种用在类型相关的 TypeScript 代码部分。
1 | let a = 1; |
上面示例中,用到了两个typeof,第一个是类型运算,第二个是值运算。它们是不一样的,不要混淆。
JavaScript 的 typeof 遵守 JavaScript 规则,TypeScript 的 typeof 遵守 TypeScript规则。它们的一个重要区别在于,编译后,前者会保留,后者会被全部删除。
由于编译时不会进行 JavaScript 的值运算,所以TypeScript 规定,typeof 的参数只能是标识符,不能是需要运算的表达式。
1 | type T = typeof Date(); // 报错 |
另外,typeof命令的参数不能是类型。
1 | type Age = number; |
4.10 块级类型声明
TypeScript 支持块级类型声明,即类型可以声明在代码块(用大括号表示)里面,并且只在当前代码块有效。
1 | if (true) { |
4.11 类型的兼容
TypeScript 的类型存在兼容关系,某些类型可以兼容其他类型。
1 | type T = number|string; |
上面示例中,变量a和b的类型是不一样的,但是变量a赋值给变量b并不会报错。这时,我们就认为,b的类型兼容a的类型。
TypeScript 为这种情况定义了一个专门术语。如果类型A的值可以赋值给类型B,那么类型A就称为类型B的子类型(subtype)。
TypeScript 的一个规则是,凡是可以使用父类型的地方,都可以使用子类型,但是反过来不行。
1 | let a:'hi' = 'hi'; |
之所以有这样的规则,是因为子类型继承了父类型的所有特征,所以可以用在父类型的场合。但是,子类型还可能有一些父类型没有的特征,所以父类型不能用在子类型的场合。
五、TypeScript 的数组类型
JavaScript 数组在 TypeScript 里面分成两种类型,分别是数组(array)和元组(tuple)。
5.1 简介
TypeScript 数组有一个根本特征:所有成员的类型必须相同,但是成员数量是不确定的,可以是无限数量的成员,也可以是零成员。
数组的类型有两种写法。第一种写法是在数组成员的类型后面,加上一对方括号。
1 | let arr:number[] = [1, 2, 3]; |
如果数组成员的类型比较复杂,可以写在圆括号里面。
1 | let arr:(number|string)[]; |
这个例子里面的圆括号是必须的,否则因为竖杠(|)的优先级低于[],TypeScript 会把number|string[]理解成number和string[]的联合类型。
如果数组成员可以是任意类型,写成any[]。当然,这种写法是应该避免的。
1 | let arr:any[]; |
数组类型的第二种写法是使用 TypeScipt 内置的 Array 接口。
1 | let arr:Array<number> = [1, 2, 3]; |
上面示例中,数组arr的类型是Array<**number**>,其中number表示成员类型是
number。
这种写法对于成员类型比较复杂的数组,代码可读性会稍微好一些。
数组类型声明了以后,成员数量是不限制的,任意数量的成员都可以,也可以是空数组。
1 | let arr:number[]; |
这种规定的隐藏含义就是,数组的成员是可以动态变化的。
正是由于成员数量可以动态变化,所以 TypeScript 不会对数组边界进行检查,越界访问数组并不会报错。
TypeScript 允许使用方括号读取数组成员的类型。
1 | type Names = string[]; |
由于数组成员的索引类型都是number,所以读取成员类型也可以写成下面这样。
1 | type Names = string[]; |
5.2 数组的类型推断
如果数组变量没有声明类型,TypeScript 就会推断数组成员的类型。这时,推断行为会因为值的不同,而有所不同。
如果变量的初始值是空数组,那么 TypeScript 会推断数组类型是any[]。
1 | // 推断为 any[] |
后面,为这个数组赋值时,TypeScript 会自动更新类型推断。
1 | // 推断为 any[] |
但是,类型推断的自动更新只发生初始值为空数组的情况。如果初始值不是空数组,类型推断就不会更新。
1 | // 推断类型为 number[] |
5.3 只读数组,const 断言
JavaScript 规定,const命令声明的数组变量是可以改变成员的。
1 | const arr = [0, 1]; |
TypeScript 允许声明只读数组,方法是在数组类型前面加上readonly关键字。
1 | const arr:readonly number[] = [0, 1]; |
上面示例中,arr是一个只读数组,删除、修改、新增数组成员都会报错。
TypeScript 将readonly number[]与number[]视为两种不一样的类型,后者是前者的子类型。
这是因为只读数组没有pop()、push()之类会改变原数组的方法,所以number[]的方法数量要多于readonly number[],这意味着number[]其实是readonly number[]的子类型。
子类型继承了父类型的所有特征,并加上了自己的特征,所以子类型number[]可以用于所有使用父类型的场合,反过来就不行。
1 | let a1:number[] = [0, 1]; |
由于只读数组是数组的父类型,所以它不能代替数组。这一点很容易产生令人困惑的报错。
1 | function getSum(s:number[]) { |
注意,readonly关键字不能与数组的泛型写法一起使用。
1 | // 报错 |
实际上,TypeScript 提供了两个专门的泛型,用来生成只读数组的类型。
1 | const a1:ReadonlyArray<number> = [0, 1]; |
泛型ReadonlyArray<**T**>和Readonly<**T[]**>都可以用来生成只读数组类型。两者尖括号里面的写法不一样,Readonly<**T[]**>的尖括号里面是整个数组(number[]),而ReadonlyArray<**T**>的尖括号里面是数组成员(number)。
只读数组还有一种声明方法,就是使用“const 断言”。
1 | const arr = [0, 1] as const; |
5.4 多维数组
TypeScript 使用T[][]的形式,表示二维数组,T是最底层数组成员的类型。
1 | var multi:number[][] = |
六、TypeScript 的元组类型
6.1 简介
元组(tuple)是 TypeScript 特有的数据类型,JavaScript 没有单独区分这种类型。它表示成员类型可以自由设置的数组,即数组的各个成员的类型可以不同。
元组必须明确声明每个成员的类型。
1 | const s:[string, string, boolean] |
元组类型的写法,与上一章的数组有一个重大差异。数组的成员类型写在方括号外面(number[]),元组的成员类型是写在方括号里面([number])。
TypeScript 的区分方法是,成员类型写在方括号里面的就是元组,写在外面的就是数组。
1 | let a:[number] = [1]; |
使用元组时,必须明确给出类型声明(上例的[number]),不能省略,否则TypeScript 会把一个值自动推断为数组。
1 | // a 的类型为 (number | boolean)[] |
元组成员的类型可以添加问号后缀(?),表示该成员是可选的。
1 | let a:[number, number?] = [1]; |
注意,问号只能用于元组的尾部成员,也就是说,所有可选成员必须在必选成员之后。
1 | type myTuple = [ |
由于需要声明每个成员的类型,所以大多数情况下,元组的成员数量是有限的,从类型声明就可以明确知道,元组包含多少个成员,越界的成员会报错。
1 | let x:[string, string] = ['a', 'b']; |
但是,使用扩展运算符(…),可以表示不限成员数量的元组。
1 | type NamedNums = [ |
扩展运算符用在元组的任意位置都可以,但是它后面只能是数组或元组。
1 | type t1 = [string, number, ...boolean[]]; |
如果不确定元组成员的类型和数量,可以写成下面这样。
1 | type Tuple = [...any[]]; |
元组可以通过方括号,读取成员类型。
1 | type Tuple = [string, number]; |
由于元组的成员都是数值索引,即索引类型都是number,所以可以像下面这样读取。
1 | type Tuple = [string, number, Date]; |
6.2 只读元组
元组也可以是只读的,不允许修改,有两种写法。
1 | // 写法一 |
上面示例中,两种写法都可以得到只读元组,其中写法二是一个泛型,用到了工具类型Readonly<**T**>。
跟数组一样,只读元组是元组的父类型。所以,元组可以替代只读元组,而只读元组不能替代元组。
1 | type t1 = readonly [number, number]; |
由于只读元组不能替代元组,所以会产生一些令人困惑的报错。
1 | function distanceFromOrigin([x, y]:[number, number]) { |
6.3 成员数量的推断
如果没有可选成员和扩展运算符,TypeScript 会推断出元组的成员数量(即元组长度)。
1 | function f(point: [number, number]) { |
如果包含了可选成员,TypeScript 会推断出可能的成员数量。
1 | function f( |
如果使用了扩展运算符,TypeScript 就无法推断出成员数量。
1 | const myTuple:[...string[]] |
6.4 扩展运算符与成员数量
扩展运算符(…)将数组(注意,不是元组)转换成一个逗号分隔的序列,这时TypeScript 会认为这个序列的成员数量是不确定的,因为数组的成员数量是不确定的。
这导致如果函数调用时,使用扩展运算符传入函数参数,可能发生参数数量与数组长度不匹配的报错。
1 | const arr = [1, 2]; |
有些函数可以接受任意数量的参数,这时使用扩展运算符就不会报错。
1 | const arr = [1, 2, 3]; |
解决这个问题的一个方法,就是把成员数量不确定的数组,写成成员数量确定的元组,再使用扩展运算符。
1 | const arr:[number, number] = [1, 2]; |
另一种写法是使用as const断言。
1 | const arr = [1, 2] as const; |
七、TypeScript 的 symbol 类型
7.1 简介
Symbol 是 ES2015 新引入的一种原始类型的值。它类似于字符串,但是每一个Symbol 值都是独一无二的,与其他任何值都不相等。
Symbol 值通过Symbol()函数生成。在 TypeScript 里面,Symbol 的类型使用symbol表示。
1 | let x:symbol = Symbol(); |
7.2 unique symbol
symbol类型包含所有的 Symbol 值,但是无法表示某一个具体的 Symbol 值。
为了解决这个问题,TypeScript 设计了symbol的一个子类型unique symbol,它表示单个的、某个具体的 Symbol 值。
因为unique symbol表示单个值,所以这个类型的变量是不能修改值的,只能用const命令声明,不能用let声明。
1 | // 正确 |
const命令为变量赋值 Symbol 值时,变量类型默认就是unique symbol,所以类型可以省略不写。
1 | const x:unique symbol = Symbol(); |
每个声明为unique symbol类型的变量,它们的值都是不一样的,其实属于两个值类型。
1 | const a:'hello' = 'hello'; |
上面示例中,变量a和b都是字符串,但是属于不同的值类型,不能使用严格相等运算符进行比较。
而且,由于变量a和b是两个类型,就不能把一个赋值给另一个。
1 | const a:unique symbol = Symbol(); |
上例变量b的类型,如果要写成与变量a同一个unique symbol值类型,只能写成类型为typeof a。
1 | const a:unique symbol = Symbol(); |
相同参数的Symbol.for()方法会返回相同的 Symbol 值。TypeScript 目前无法识别这种情况,所以可能出现多个 unique symbol 类型的变量,等于同一个 Symbol 值的情况。
1 | const a:unique symbol = Symbol.for('foo'); |
unique symbol 类型是 symbol 类型的子类型,所以可以将前者赋值给后者,但是反过来就不行。
1 | const a:unique symbol = Symbol(); |
unique symbol 类型的一个作用,就是用作属性名,这可以保证不会跟其他属性名冲突。如果要把某一个特定的 Symbol 值当作属性名,那么它的类型只能是 unique symbol,不能是 symbol。
1 | const x:unique symbol = Symbol(); |
unique symbol类型也可以用作类(class)的属性值,但只能赋值给类的readonly static属性。
1 | class C { |
注意,这时static和readonly两个限定符缺一不可,这是为了保证这个属性是固定不变的。
7.3 类型推断
如果变量声明时没有给出类型,TypeScript 会推断某个 Symbol 值变量的类型。
let命令声明的变量,推断类型为 symbol。
const命令声明的变量,推断类型为 unique symbol。
但是,const命令声明的变量,如果赋值为另一个 symbol 类型的变量,则推断类型为 symbol。
1 | let x = Symbol(); |
let命令声明的变量,如果赋值为另一个 unique symbol 类型的变量,则推断类型还是 symbol。
1 | const x = Symbol(); |
八、TypeScript 的函数类型
8.1 简介
函数的类型声明,需要在声明函数时,给出参数的类型和返回值的类型。
1 | function hello( |
上面示例中,函数hello()在声明时,需要给出参数txt的类型(string),以及返回值的类型(void),后者写在参数列表的圆括号后面。void类型表示没有返回值。
如果不指定参数类型(比如上例不写txt的类型),TypeScript 就会推断参数类型,如果缺乏足够信息,就会推断该参数的类型为any。
返回值的类型通常可以不写,因为 TypeScript 自己会推断出来。
1 | function hello(txt:string) { |
如果变量被赋值为一个函数,变量的类型有两种写法。
1 | // 写法一 |
上面示例中,变量hello被赋值为一个函数,它的类型有两种写法。写法一是通过等号右边的函数类型,推断出变量hello的类型;写法二则是使用箭头函数的形式,为变量hello指定类型,参数的类型写在箭头左侧,返回值的类型写在箭头右侧。
写法二有两个地方需要注意。
首先,函数的参数要放在圆括号里面,不放会报错。
其次,类型里面的参数名(本例是txt)是必须的。
函数类型里面的参数名与实际参数名,可以不一致。
1 | let f:(x:number) => number; |
如果函数的类型定义很冗长,或者多个函数使用同一种类型,写法二用起来就很麻烦。因此,往往用type命令为函数类型定义一个别名,便于指定给其他变量。
1 | type MyFunc = (txt:string) => void; |
函数的实际参数个数,可以少于类型指定的参数个数,但是不能多于,即TypeScript 允许省略参数。
1 | let myFunc: |
如果一个变量要套用另一个函数类型,有一个小技巧,就是使用typeof运算符。
1 | function add( |
函数类型还可以采用对象的写法。
1 | let add:{ |
函数类型的对象写法如下。
1 | { |
注意,这种写法的函数参数与返回值之间,间隔符是冒号:,而不是正常写法的箭头=>,因为这里采用的是对象类型的写法,对象的属性名与属性值之间使用的是冒号。
这种写法平时很少用,但是非常合适用在一个场合:函数本身存在属性。
1 | function f(x:number) { |
上面示例中,函数f()本身还有一个属性foo。这时,f完全就是一个对象,类型就要使用对象的写法。
1 | let foo: { |
函数类型也可以使用 Interface 来声明,这种写法就是对象写法的翻版。
1 | interface myfn { |
上面示例中,interface 命令定义了接口myfn,这个接口的类型就是一个用对象表示的函数。
8.2 Function 类型
TypeScript 提供 Function 类型表示函数,任何函数都属于这个类型。
1 | function doSomething(f:Function) { |
Function 类型的值都可以直接执行。
Function 类型的函数可以接受任意数量的参数,每个参数的类型都是any,返回值的类型也是any,代表没有任何约束,所以不建议使用这个类型,给出函数详细的类型声明会更好。
8.3 箭头函数
箭头函数是普通函数的一种简化写法,它的类型写法与普通函数类似。
1 | const repeat = ( |
注意,类型写在箭头函数的定义里面,与使用箭头函数表示函数类型,写法有所不同。
1 | function greet( |
上面示例中,函数greet()的参数fn是一个函数,类型就用箭头函数表示。这时,fn的返回值类型要写在箭头右侧,而不是写在参数列表的圆括号后面。
下面再看一个例子。
1 | type Person = { name: string }; |
上面示例中,Person是一个类型别名,代表一个对象,该对象有属性name。变量people是数组的map()方法的返回值。
map()方法的参数是一个箭头函数(name):Person => ({name}),该箭头函数的参数name的类型省略了,因为可以从map()的类型定义推断出来,箭头函数的返回值类型为Person。相应地,变量people的类型是Person[]。
至于箭头后面的({name}),表示返回一个对象,该对象有一个属性name,它的属性值为变量name的值。这里的圆括号是必须的,否则(name):Person => {name}的大括号表示函数体,即函数体内有一行语句name,同时由于没有return语句,这个函数不会返回任何值。
8.4 可选参数
如果函数的某个参数可以省略,则在参数名后面加问号表示。
1 | function f(x?:number) { |
参数名带有问号,表示该参数的类型实际上是原始类型|undefined,它有可能为undefined。比如,上例的x虽然类型声明为number,但是实际上是number|undefined。
但是,反过来就不成立,类型显式设为undefined的参数,就不能省略。
函数的可选参数只能在参数列表的尾部,跟在必选参数的后面。
如果前部参数有可能为空,这时只能显式注明该参数类型可能为undefined。
函数体内部用到可选参数时,需要判断该参数是否为undefined。
1 | let myFunc: |
8.5 参数默认值
TypeScript 函数的参数默认值写法,与 JavaScript 一致。
设置了默认值的参数,就是可选的。如果不传入该参数,它就会等于默认值。
1 | function createPoint( |
可选参数与默认值不能同时使用。
设有默认值的参数,如果传入undefined,也会触发默认值。
1 | function f(x = 456) { |
具有默认值的参数如果不位于参数列表的末尾,调用时不能省略,如果要触发默认值,必须显式传入undefined。
1 | function add( |
8.6 参数解构
函数参数如果存在变量解构,类型写法如下。
1 | function f( |
参数结构可以结合类型别名(type 命令)一起使用,代码会看起来简洁一些。
1 | type ABC = { a:number; b:number; c:number }; |
8.7 rest 参数
rest 参数表示函数剩余的所有参数,它可以是数组(剩余参数类型相同),也可能是元组(剩余参数类型不同)。
1 | // rest 参数为数组 |
注意,元组需要声明每一个剩余参数的类型。如果元组里面的参数是可选的,则要使用可选参数。
rest 参数甚至可以嵌套。
rest 参数可以与变量解构结合使用。
1 | function repeat( |
8.8 readonly 只读参数
如果函数内部不能修改某个参数,可以在函数定义时,在参数类型前面加上readonly关键字,表示这是只读参数。
1 | function arraySum( |
8.9 void 类型
void 类型表示函数没有返回值。
1 | function f():void { |
如果返回其他值,就会报错。
void 类型允许返回undefined或null。
1 | function f():void { |
如果打开了strictNullChecks编译选项,那么 void 类型只允许返回undefined。如果返回null,就会报错。这是因为 JavaScript 规定,如果函数没有返回值,就等同于返回undefined。
需要特别注意的是,如果变量、对象方法、函数参数的类型是 void 类型的函数,那么并不代表不能赋值为有返回值的函数。恰恰相反,该变量、对象方法和函数参数可以接受返回任意值的函数,这时并不会报错。
1 | type voidFunc = () => void; |
注意,这种情况仅限于变量、对象方法和函数参数,函数字面量如果声明了返回值是void 类型,还是不能有返回值。
除了函数,其他变量声明为void类型没有多大意义,因为这时只能赋值为undefined或者null(假定没有打开strictNullChecks) 。
1 | let foo:void = undefined; |
8.10 never 类型
never类型表示肯定不会出现的值。它用在函数的返回值,就表示某个函数肯定不会返回值,即函数不会正常执行结束。
它主要有以下两种情况。
(1)抛出错误的函数。
1 | function fail(msg:string):never { |
注意,只有抛出错误,才是 never 类型。如果显式用return语句返回一个 Error 对象,返回值就不是 never 类型。
1 | function fail():Error { |
(2)无限执行的函数。
1 | const sing = function():never { |
注意,never类型不同于void类型。前者表示函数没有执行结束,不可能有返回值;后者表示函数正常执行结束,但是不返回值,或者说返回undefined。
1 | // 正确 |
上面示例中,函数sing()虽然没有return语句,但实际上是省略了return undefined这行语句,真实的返回值是undefined。所以,它的返回值类型要写成void,而不是never,写成never会报错。
如果一个函数抛出了异常或者陷入了死循环,那么该函数无法正常返回一个值,因此该函数的返回值类型就是never。如果程序中调用了一个返回值类型为never的函数,那么就意味着程序会在该函数的调用位置终止,永远不会继续执行后续的代码。
1 | function neverReturns():never { |
8.11 局部类型
函数内部允许声明其他类型,该类型只在函数内部有效,称为局部类型。
1 | function hello(txt:string) { |
8.12 高阶函数
一个函数的返回值还是一个函数,那么前一个函数就称为高阶函数(higher-order function)。
下面就是一个例子,箭头函数返回的还是一个箭头函数。
1 | (someValue: number) => (multiplier: number) => someValue * |
8.13 函数重载
有些函数可以接受不同类型或不同个数的参数,并且根据参数的不同,会有不同的函数行为。这种根据参数类型不同,执行不同逻辑的行为,称为函数重载(function overload)。
1 | reverse('abc') // 'cba' |
这意味着,该函数内部有处理字符串和数组的两套逻辑,根据参数类型的不同,分别执行对应的逻辑。这就叫“函数重载”。
TypeScript 对于“函数重载”的类型声明方法是,逐一定义每一种情况的类型。
1 | function reverse(str:string):string; |
上面示例中,分别对函数reverse()的两种参数情况,给予了类型声明。但是,到这里还没有结束,后面还必须对函数reverse()给予完整的类型声明。
1 | function reverse(str:string):string; |
注意,重载的个别类型描述与函数的具体实现之间,不能有其他代码,否则报错。
另外,虽然函数的具体实现里面,有完整的类型声明。但是,函数实际调用的类型,以前面的类型声明为准。
函数重载的每个类型声明之间,以及类型声明与函数实现的类型之间,不能有冲突。
1 | // 报错 |
重载声明的排序很重要,因为 TypeScript 是按照顺序进行检查的,一旦发现符合某个类型声明,就不再往下检查了,所以类型最宽的声明应该放在最后面,防止覆盖其他类型声明。
对象的方法也可以使用重载。
1 | class StringBuilder { |
函数重载也可以用来精确描述函数参数与返回值之间的对应关系。
由于重载是一种比较复杂的类型声明方法,为了降低复杂性,一般来说,如果可以的话,应该优先使用联合类型替代函数重载。
1 | // 写法一 |
8.14 构造函数
JavaScript 语言使用构造函数,生成对象的实例。
构造函数的最大特点,就是必须使用new命令调用。
构造函数的类型写法,就是在参数列表前面加上new命令。
1 | class Animal { |
构造函数还有另一种类型写法,就是采用对象形式。
1 | type F = { |
某些函数既是构造函数,又可以当作普通函数使用,比如Date()。这时,类型声明可以写成下面这样。
1 | type F = { |
九、TypeScript 的对象类型
9.1 简介
对象类型的最简单声明方法,就是使用大括号表示对象,在大括号内部声明每个属性和方法的类型。
1 | const obj:{ |
属性的类型可以用分号结尾,也可以用逗号结尾。
1 | // 属性类型以分号结尾 |
最后一个属性后面,可以写分号或逗号,也可以不写。
一旦声明了类型,对象赋值时,就不能缺少指定的属性,也不能有多余的属性。
1 | type MyObj = { |
读写不存在的属性也会报错。
同样地,也不能删除类型声明中存在的属性,修改属性值是可以的。
对象的方法使用函数类型描述。
1 | const obj:{ |
对象类型可以使用方括号读取属性的类型。
1 | type User = { |
除了type命令可以为对象类型声明一个别名,TypeScript 还提供了interface命令,可以把对象类型提炼为一个接口。
1 | // 写法一 |
注意,TypeScript 不区分对象自身的属性和继承的属性,一律视为对象的属性。
1 | interface MyInterface { |
9.2 可选属性
如果某个属性是可选的(即可以忽略),需要在属性名后面加一个问号。
1 | const obj: { |
可选属性等同于允许赋值为undefined,下面两种写法是等效的。
1 | type User = { |
同理,读取一个可选属性时,有可能返回undefined。
1 | type MyObj = { |
所以,读取可选属性之前,必须检查一下是否为undefined。
1 | const user:{ |
建议使用下面的写法。
1 | // 写法一 |
9.3 只读属性
属性名前面加上readonly关键字,表示这个属性是只读属性,不能修改。
1 | interface MyInterface { |
只读属性只能在对象初始化期间赋值,此后就不能修改该属性。
1 | type Point = { |
注意,如果属性值是一个对象,readonly修饰符并不禁止修改该对象的属性,只是禁止完全替换掉该对象。
1 | interface Home { |
另一个需要注意的地方是,如果一个对象有两个引用,即两个变量对应同一个对象,其中一个变量是可写的,另一个变量是只读的,那么从可写变量修改属性,会影响到只读变量。
1 | interface Person { |
如果希望属性值是只读的,除了声明时加上readonly关键字,还有一种方法,就是在赋值时,在对象后面加上只读断言as const。
1 | const myUser = { |
注意,上面的as const属于 TypeScript 的类型推断,如果变量明确地声明了类型,那么 TypeScript 会以声明的类型为准。
9.4 属性名的索引类型
如果对象的属性非常多,一个个声明类型就很麻烦,而且有些时候,无法事前知道对象会有多少属性,比如外部 API 返回的对象。这时 TypeScript 允许采用属性名表达式的写法来描述类型,称为“属性名的索引类型”。
索引类型里面,最常见的就是属性名的字符串索引。
1 | type MyObj = { |
JavaScript 对象的属性名(即上例的property)的类型有三种可能,除了上例的string,还有number和symbol。
1 | type T1 = { |
对象可以同时有多种类型的属性名索引,比如同时有数值索引和字符串索性。但是,数值索性不能与字符串索引发生冲突,必须服从后者,这是因为在 JavaScript 语言内部,所有的数值属性名都会自动转为字符串属性名。
同样地,可以既声明属性名索引,也声明具体的单个属性名。如果单个属性名符合属性名索引的范围,两者不能有冲突,否则报错。
1 | type MyType = { |
属性的索引类型写法,建议谨慎使用,因为属性名的声明太宽泛,约束太少。另外,属性名的数值索引不宜用来声明数组,因为采用这种方式声明数组,就不能使用各种数组方法以及length属性,因为类型里面没有定义这些东西。
1 | type MyArr = { |
9.5 解构赋值
解构赋值用于直接从对象中提取属性。
1 | const {id, name, price} = product; |
解构赋值的类型写法,跟为对象声明类型是一样的。
1 | const {id, name, price}:{ |
注意,目前没法为解构变量指定类型,因为对象解构里面的冒号,JavaScript 指定了其他用途。
1 | let { x: foo, y: bar } = obj; |
上面示例中,冒号不是表示属性x和y的类型,而是为这两个属性指定新的变量名。如果要为x和y指定类型,不得不写成下面这样。
1 | let { x: foo, y: bar } |
1 | function draw({ |
上面示例中,函数draw()的参数是一个对象解构,里面的冒号很像是为变量指定类型,其实是为对应的属性指定新的变量名。所以,TypeScript 就会解读成,函数体内不存在变量shape,而是属性shape的值被赋值给了变量Shape。
9.6 结构类型原则
只要对象 B 满足 对象 A 的结构特征,TypeScript 就认为对象 B 兼容对象 A 的类型,这称为“结构类型”原则(structual typing)。
1 | const A = { |
根据“结构类型”原则,TypeScript 检查某个值是否符合指定类型时,并不是检查这个值的类型名(即“名义类型”),而是检查这个值的结构是否符合要求(即“结构类型”)。
TypeScript 之所以这样设计,是为了符合 JavaScript 的行为。JavaScript 并不关心对象是否严格相似,只要某个对象具有所要求的属性,就可以正确运行。
如果类型 B 可以赋值给类型 A,TypeScript 就认为 B 是 A 的子类型(subtyping),A 是 B 的父类型。子类型满足父类型的所有结构特征,同时还具有自己的特征。凡是可以使用父类型的地方,都可以使用子类型,即子类型兼容父类型。
9.7 严格字面量检查
如果对象使用字面量表示,会触发 TypeScript 的严格字面量检查(strict object literal checking)。如果字面量的结构跟类型定义的不一样(比如多出了未定义的属性),就会报错。
1 | const point:{ |
如果等号右边不是字面量,而是一个变量,根据结构类型原则,是不会报错的。
1 | const myPoint = { |
TypeScript 对字面量进行严格检查的目的,主要是防止拼写错误。
规避严格字面量检查,可以使用中间变量。
1 | let myOptions = { |
如果你确认字面量没有错误,也可以使用类型断言规避严格字面量检查。
1 | const Obj:Options = { |
如果允许字面量有多余属性,可以像下面这样在类型里面定义一个通用属性。
1 | let x: { |
由于严格字面量检查,字面量对象传入函数必须很小心,不能有多余的属性。
1 | interface Point { |
编译器选项suppressExcessPropertyErrors,可以关闭多余属性检查。下面是它在 tsconfig.json 文件里面的写法。
1 | { |
9.8 最小可选属性规则
如果一个对象的所有属性都是可选的,会触发最小可选属性规则。
1 | type Options = { |
为了避免这种情况,TypeScript 添加了最小可选属性规则,规定这时属于Options类型的对象,必须至少存在一个可选属性,不能所有可选属性都不存在。这就是为什么上例的myObj对象会报错的原因。
这条规则无法通过中间变量规避。
1 | const myOptions = { d: 123 }; |
9.9 空对象
空对象是 TypeScript 的一种特殊值,也是一种特殊类型。
空对象没有自定义属性,所以对自定义属性赋值就会报错。空对象只能使用继承的属性,即继承自原型对象Object.prototype的属性。
TypeScript 不允许动态添加属性,所以对象不能分步生成,必须生成时一次性声明所有属性。
1 | // 错误 |
如果确实需要分步声明,一个比较好的方法是,使用扩展运算符(…)合成一个新对象。
1 | const pt0 = {}; |
空对象作为类型,其实是Object类型的简写形式。
1 | let d:{}; |
因为Object可以接受各种类型的值,而空对象是Object类型的简写,所以它不会有严格字面量检查,赋值时总是允许多余的属性,只是不能读取这些属性。
1 | interface Empty { } |
如果想强制使用没有任何属性的对象,可以采用下面的写法。
1 | interface WithoutProperties { |
十、 TypeScript 的 interface 接口
10.1 简介
interface 是对象的模板,可以看作是一种类型约定,中文译为“接口”。使用了某个模板的对象,就拥有了指定的类型结构。
1 | interface Person { |
实现该接口很简单,只要指定它作为对象的类型即可。
1 | const p:Person = { |
方括号运算符可以取出 interface 某个属性的类型。
1 | interface Foo { |
interface 可以表示对象的各种语法,它的成员有5种形式。
对象属性
对象的属性索引
对象方法
函数
构造函数
(1)对象属性
1 | interface Point { |
属性之间使用分号或逗号分隔,最后一个属性结尾的分号或逗号可以省略。
如果属性是可选的,就在属性名后面加一个问号。
如果属性是只读的,需要加上readonly修饰符。
1 | interface A { |
(2)对象的属性索引
1 | interface A { |
属性索引共有string、number和symbol三种类型。
一个接口中,最多只能定义一个字符串索引。字符串索引会约束该类型中所有名字为字符串的属性。
1 | interface MyObj { |
属性的数值索引,其实是指定数组的类型。
1 | interface A { |
同样的,一个接口中最多只能定义一个数值索引。数值索引会约束所有名称为数值的属性。
如果一个 interface 同时定义了字符串索引和数值索引,那么数值索性必须服从于字符串索引。因为在 JavaScript 中,数值属性名最终是自动转换成字符串属性名。
1 | interface A { |
数值索引必须兼容字符串索引的类型声明。
(3)对象的方法
对象的方法共有三种写法。
1 | // 写法一 |
属性名可以采用表达式,所以下面的写法也是可以的。
1 | const f = 'f'; |
类型方法可以重载。
1 | interface A { |
interface 里面的函数重载,不需要给出实现。但是,由于对象内部定义方法时,无法使用函数重载的语法,所以需要额外在对象外部给出函数方法的实现。
1 | interface A { |
(4)函数
interface 也可以用来声明独立的函数。
1 | interface Add { |
(5)构造函数
interface 内部可以使用new关键字,表示构造函数。
1 | interface ErrorConstructor { |
10.2 interface 的继承
10.2.1 interface 继承 interface
interface 可以使用extends关键字,继承其他 interface。
1 | interface Shape { |
extends关键字会从继承的接口里面拷贝属性类型,这样就不必书写重复的属性。
interface 允许多重继承。
1 | interface Style { |
多重接口继承,实际上相当于多个父接口的合并。
如果子接口与父接口存在同名属性,那么子接口的属性会覆盖父接口的属性。注意,子接口与父接口的同名属性必须是类型兼容的,不能有冲突,否则会报错。
1 | interface Foo { |
多重继承时,如果多个父接口存在同名属性,那么这些同名属性不能有类型冲突,否则会报错。
1 | interface Foo { |
10.2.2 interface 继承 type
interface 可以继承type命令定义的对象类型。
1 | type Country = { |
注意,如果type命令定义的类型不是对象,interface 就无法继承。
10.2.3 interface 继承 class
inteface 还可以继承 class,即继承该类的所有成员。
1 | class A { |
某些类拥有私有成员和保护成员,interface 可以继承这样的类,但是意义不大。
10.3 接口合并
多个同名接口会合并成一个接口。
1 | interface Box { |
这样的设计主要是为了兼容 JavaScript 的行为。JavaScript 开发者常常对全局对象或者外部库,添加自己的属性和方法。那么,只要使用 interface 给出这些自定义属性和方法的类型,就能自动跟原始的 interface 合并,使得扩展外部类型非常方便。
同名接口合并时,同一个属性如果有多个类型声明,彼此不能有类型冲突。
1 | interface A { |
同名接口合并时,如果同名方法有不同的类型声明,那么会发生函数重载。而且,后面的定义比前面的定义具有更高的优先级。
1 | interface Cloner { |
这个规则有一个例外。同名方法之中,如果有一个参数是字面量类型,字面量类型有更高的优先级。
1 | interface A { |
一个实际的例子是 Document 对象的createElement()方法,它会根据参数的不同,而生成不同的 HTML 节点对象。
1 | interface Document { |
如果两个 interface 组成的联合类型存在同名属性,那么该属性的类型也是联合类型。
1 | interface Circle { |
10.4 interface 与 type 的异同
interface命令与type命令作用类似,都可以表示对象类型。
很多对象类型即可以用 interface 表示,也可以用 type 表示。而且,两者往往可以换用,几乎所有的 interface 命令都可以改写为 type 命令。
它们的相似之处,首先表现在都能为对象类型起名。
1 | type Country = { |
class命令也有类似作用,通过定义一个类,同时定义一个对象类型。但是,它会创造一个值,编译后依然存在。如果只是单纯想要一个类型,应该使用type或interface。
interface 与 type 的区别有下面几点。
(1)type能够表示非对象类型,而interface只能表示对象类型(包括数组、函数等)。
(2)interface可以继承其他类型,type不支持继承。
继承的主要作用是添加属性,type定义的对象类型如果想要添加属性,只能使用&运算符,重新定义一个类型。
1 | type Animal = { |
作为比较,interface添加属性,采用的是继承的写法。
1 | interface Animal { |
继承时,type 和 interface 是可以换用的。interface 可以继承 type。
1 | type Foo = { x: number; }; |
type 也可以继承 interface。
1 | interface Foo { |
(3)同名interface会自动合并,同名type则会报错。也就是说,TypeScript 不允许使用type多次定义同一个类型。
1 | type A = { foo:number }; // 报错 |
作为比较,interface则会自动合并。
这表明,inteface 是开放的,可以添加属性,type 是封闭的,不能添加属性,只能定义新的 type。
(4)interface不能包含属性映射(mapping),type可以。
1 | interface Point { |
(5)this关键字只能用于interface。
1 | // 正确 |
(6)type 可以扩展原始数据类型,interface 不行。
1 | // 正确 |
(7)interface无法表达某些复杂类型(比如交叉类型和联合类型),但是type可以。
1 | type A = { /* ... */ }; |
十一、TypeScript 的 class 类型
11.1 简介
类(class)是面向对象编程的基本构件,封装了属性和方法,TypeScript 给予了全面支持。
11.1.1 属性的类型
类的属性可以在顶层声明,也可以在构造方法内部声明。
对于顶层声明的属性,可以在声明时同时给出类型。
1 | class Point { |
如果不给出类型,TypeScript 会认为x和y的类型都是any。
1 | class Point { |
如果声明时给出初值,可以不写类型,TypeScript 会自行推断属性的类型。
1 | class Point { |
TypeScript 有一个配置项strictPropertyInitialization,只要打开,就会检查属性是否设置了初值,如果没有就报错。
如果你打开了这个设置,但是某些情况下,不是在声明时赋值或在构造方法里面赋值,为了防止这个设置报错,可以使用非空断言。
1 | class Point { |
上面示例中,属性x和y没有初值,但是属性名后面添加了感叹号,表示这两个属性肯定不会为空,所以 TypeScript 就不报错了。
11.1.2 readonly 修饰符
属性名前面加上 readonly 修饰符,就表示该属性是只读的。实例对象不能修改这个属性。
1 | class A { |
readonly 属性的初始值,可以写在顶层属性,也可以写在构造方法里面。
1 | class A { |
1 | class A { |
上面示例中,构造方法修改只读属性的值也是可以的。或者说,如果两个地方都设置了只读属性的值,以构造方法为准。在其他方法修改只读属性都会报错。
11.1.3 方法的类型
类的方法就是普通函数,类型声明方式与函数一致。
1 | class Point { |
上面示例中,构造方法constructor()和普通方法add()都注明了参数类型,但是省略了返回值类型,因为 TypeScript 可以自己推断出来。
类的方法跟普通函数一样,可以使用参数默认值,以及函数重载。
下面是参数默认值的例子。
1 | class Point { |
下面是函数重载的例子。
1 | class Point { |
另外,构造方法不能声明返回值类型,否则报错,因为它总是返回实例对象。
1 | class B { |
11.1.4 存取器方法
存取器(accessor)是特殊的类方法,包括取值器(getter)和存值器(setter)两种方法。
它们用于读写某个属性,取值器用来读取属性,存值器用来写入属性。
1 | class C { |
get name()是取值器,其中get是关键词,name是属性名。外部读取name属性时,实例对象会自动调用这个方法,该方法的返回值就是name属性的值。
set name()是存值器,其中set是关键词,name是属性名。外部写入name属性时,实例对象会自动调用这个方法,并将所赋的值作为函数参数传入。
TypeScript 对存取器有以下规则。
(1)如果某个属性只有get方法,没有set方法,那么该属性自动成为只读属性。
1 | class C { |
(2)set方法的参数类型,必须兼容get方法的返回值类型,否则报错。
1 | class C { |
1 | class C { |
上面示例中,set方法的参数类型(number|return)兼容get方法的返回值类型(string),这是允许的。但是,最终赋值的时候,还是必须保证与get方法的返回值类型一致。
另外,如果set方法的参数没有指定类型,那么会推断为与get方法返回值类型一致。
(3)get方法与set方法的可访问性必须一致,要么都为公开方法,要么都为私有方法。
11.1.5 属性索引
类允许定义属性索引。
1 | class MyClass { |
上面示例中,[s:string]表示所有属性名类型为字符串的属性,它们的属性值要么是布尔值,要么是返回布尔值的函数。
注意,由于类的方法是一种特殊属性(属性值为函数的属性),所以属性索引的类型定义也涵盖了方法。如果一个对象同时定义了属性索引和方法,那么前者必须包含后者的类型。
1 | class MyClass { |
上面示例中,属性索引的类型里面不包括方法,导致后面的方法f()定义直接报错。
1 | class MyClass { |
属性存取器等同于方法,也必须包括在属性索性里面。
11.2 类的 interface 接口
11.2.1 implements 关键字
interface 接口或 type 别名,可以用对象的形式,为 class 指定一组检查条件。然后,类使用 implements 关键字,表示当前类满足这些外部类型条件的限制。
1 | interface Country { |
上面示例中,interface或type都可以定义一个对象类型。类MyCountry使用implements关键字,表示该类的实例对象满足这个外部类型。
interface 只是指定检查条件,如果不满足这些条件就会报错。它并不能代替 class自身的类型声明。
1 | interface A { |
类B实现了接口A,但是后者并不能代替B的类型声明。因此,B的get()方法的参数s的类型是any,而不是string。B类依然需要声明参数s的类型。
1 | class B implements A { |
下面是另一个例子。
1 | interface A { |
上面示例中,接口A有一个可选属性y,类B没有声明这个属性,所以可以通过类型检查。但是,如果给B的实例对象的属性y赋值,就会报错。所以,B类还是需要声明可选属性y。
1 | class B implements A { |
同理,类可以定义接口没有声明的方法和属性。
1 | interface Point { |
implements关键字后面,不仅可以是接口,也可以是另一个类。这时,后面的类将被当作接口。
1 | class Car { |
上面示例中,implements后面是类Car,这时 TypeScript 就把Car视为一个接口,要求MyCar实现Car里面的每一个属性和方法,否则就会报错。所以,这时不能因为Car类已经实现过一次,而在MyCar类省略属性或方法。
注意,interface 描述的是类的对外接口,也就是实例的公开属性和公开方法,不能定义私有的属性和方法。这是因为 TypeScript 设计者认为,私有属性是类的内部实现,接口作为模板,不应该涉及类的内部代码写法。
11.2.2 实现多个接口
类可以实现多个接口(其实是接受多重限制),每个接口之间使用逗号分隔。
1 | class Car implements MotorVehicle, Flyable, Swimmable { |
它必须部署这三个接口声明的所有属性和方法,满足它们的所有条件。
但是,同时实现多个接口并不是一个好的写法,容易使得代码难以管理,可以使用两种方法替代。
第一种方法是类的继承。
1 | class Car implements MotorVehicle { |
第二种方法是接口的继承。
1 | interface A { |
注意,发生多重实现时(即一个接口同时实现多个接口),不同接口不能有互相冲突的属性。
11.2.3 类与接口的合并
TypeScript 不允许两个同名的类,但是如果一个类和一个接口同名,那么接口会被合并进类。
1 | class A { |
11.3 Class 类型
11.3.1 实例类型
TypeScript 的类本身就是一种类型,但是它代表该类的实例类型,而不是 class 的自身类型。
1 | class Color { |
上面示例中,定义了一个类Color。它的类名就代表一种类型,实例对象green就属于该类型。
对于引用实例对象的变量来说,既可以声明类型为 Class,也可以声明类型为Interface,因为两者都代表实例对象的类型。
1 | interface MotorVehicle { |
上面示例中,变量c的类型可以写成类Car,也可以写成接口MotorVehicle。它们的区别是,如果类Car有接口MotoVehicle没有的属性和方法,那么只有变量c1可以调用这些属性和方法。
作为类型使用时,类名只能表示实例的类型,不能表示类的自身类型。
1 | class Point { |
上面示例中,函数createPoint()的第一个参数PointClass,需要传入 Point 这个类,但是如果把参数的类型写成Point就会报错,因为Point描述的是实例类型,而不是 Class 的自身类型。
由于类名作为类型使用,实际上代表一个对象,因此可以把类看作为对象类型起名。事实上,TypeScript 有三种方法可以为对象类型起名:type、interface 和 class。
11.3.2 类的自身类型
要获得一个类的自身类型,一个简便的方法就是使用 typeof 运算符。
1 | function createPoint( |
上面示例中,createPoint()的第一个参数PointClass是Point类自身,要声明这个参数的类型,简便的方法就是使用typeof Point。因为Point类是一个值,typeof Point返回这个值的类型。注意,createPoint()的返回值类型是Point,代表实例类型。
JavaScript 语言中,类只是构造函数的一种语法糖,本质上是构造函数的另一种写法。所以,类的自身类型可以写成构造函数的形式。
1 | function createPoint( |
构造函数也可以写成对象形式,所以参数PointClass的类型还有另一种写法。
1 | function createPoint( |
根据上面的写法,可以把构造函数提取出来,单独定义一个接口(interface),这样可以大大提高代码的通用性。
1 | interface PointConstructor { |
总结一下,类的自身类型就是一个构造函数,可以单独定义一个接口来表示。
11.3.3 结构类型原则
Class 也遵循“结构类型原则”。一个对象只要满足 Class 的实例结构,就跟该 Class属于同一个类型。
1 | class Foo { |
如果两个类的实例结构相同,那么这两个类就是兼容的,可以用在对方的使用场合。
1 | class Person { |
上面示例中,Person和Customer是两个结构相同的类,TypeScript 将它们视为相同类型,因此Person可以用在类型为Customer的场合。
现在修改一下代码,Person类添加一个属性。
1 | class Person { |
上面示例中,Person类添加了一个属性age,跟Customer类的结构不再相同。但是这种情况下,TypeScript 依然认为,Person属于Customer类型。
这是因为根据“结构类型原则”,只要Person类具有name属性,就满足Customer类型的实例结构,所以可以代替它。反过来就不行,如果Customer类多出一个属性,就会报错。
1 | class Person { |
总之,只要 A 类具有 B 类的结构,哪怕还有额外的属性和方法,TypeScript 也认为A 兼容 B 的类型。
不仅是类,如果某个对象跟某个 class 的实例结构相同,TypeScript 也认为两者的类型相同。
1 | class Person { |
由于这种情况,运算符instanceof不适用于判断某个对象是否跟某个 class 属于同一类型。
1 | obj instanceof Person // false |
上面示例中,运算符instanceof确认变量obj不是 Person 的实例,但是两者的类型是相同的。
空类不包含任何成员,任何其他类都可以看作与空类结构相同。因此,凡是类型为空类的地方,所有类(包括对象)都可以使用。
1 | class Empty {} |
注意,确定两个类的兼容关系时,只检查实例成员,不考虑静态成员和构造方法。
1 | class Point { |
上面示例中,Point与Position的静态属性和构造方法都不一样,但因为Point的实例成员与Position相同,所以Position兼容Point。
如果类中存在私有成员(private)或保护成员(protected),那么确定兼容关系时,TypeScript 要求私有成员和保护成员来自同一个类,这意味着两个类需要存在继承关系。
1 | // 情况一 |
上面示例中,A和B都有私有成员(或保护成员)name,这时只有在B继承A的情况下(class B extends A),B才兼容A。
11.4 类的继承
类(这里又称“子类”)可以使用 extends 关键字继承另一个类(这里又称“基类”)的所有属性和方法。
1 | class A { |
根据结构类型原则,子类也可以用于类型为基类的场合。
1 | const a:A = b; |
子类可以覆盖基类的同名方法。
1 | class B extends A { |
上面示例中,子类B定义了一个方法greet(),覆盖了基类A的同名方法。其中,参数name省略时,就调用基类A的greet()方法,这里可以写成super.greet(),使用super关键字指代基类是常见做法。
但是,子类的同名方法不能与基类的类型定义相冲突。
1 | class A { |
如果基类包括保护成员(protected修饰符),子类可以将该成员的可访问性设置为公开(public修饰符),也可以保持保护成员不变,但是不能改用私有成员(private修饰符)。
1 | class A { |
注意,extends关键字后面不一定是类名,可以是一个表达式,只要它的类型是构造函数就可以了。
1 | // 例一 |
对于那些只设置了类型、没有初值的顶层属性,有一个细节需要注意。
1 | interface Animal { |
上面示例中,类DogHouse的顶层成员resident只设置了类型(Dog),没有设置初值。这段代码在不同的编译设置下,编译结果不一样。
如果编译设置的target设成大于等于ES2022,或者useDefineForClassFields设成true,那么下面代码的执行结果是不一样的。
1 | const dog = { |
上面示例中,DogHouse实例的属性resident输出的是undefined,而不是预料的dog。原因在于 ES2022 标准的 Class Fields 部分,与早期的 TypeScript 实现不一致,导致子类的那些只设置类型、没有设置初值的顶层成员在基类中被赋值后,会在子类被重置为undefined。
解决方法就是使用declare命令,去声明顶层成员的类型,告诉 TypeScript 这些成员的赋值由基类实现。
1 | class DogHouse extends AnimalHouse { |
上面示例中,resident属性的类型声明前面用了declare命令,这样就能确保在编译目标大于等于ES2022时(或者打开useDefineForClassFields时),代码行为正确。
11.5 可访问性修饰符
类的内部成员的外部可访问性,由三个可访问性修饰符(access modifiers)控制:public、private和protected。
这三个修饰符的位置,都写在属性或方法的最前面。
11.5.1 public
public修饰符表示这是公开成员,外部可以自由访问。
1 | class Greeter { |
public修饰符是默认修饰符,如果省略不写,实际上就带有该修饰符。因此,类的属性和方法默认都是外部可访问的。
正常情况下,除非为了醒目和代码可读性,public都是省略不写的。
11.5.2 private
private修饰符表示私有成员,只能用在当前类的内部,类的实例和子类都不能使用该成员。
1 | class A { |
注意,子类不能定义父类私有成员的同名成员。
1 | class A { |
如果在类的内部,当前类的实例可以获取私有成员。
1 | class A { |
严格地说,private定义的私有成员,并不是真正意义的私有成员。一方面,编译成 JavaScript 后,private关键字就被剥离了,这时外部访问该成员就不会报错。另一方面,由于前一个原因,TypeScript 对于访问private成员没有严格禁止,使用方括号写法([])或者in运算符,实例对象就能访问该成员。
1 | class A { |
由于private存在这些问题,加上它是 ES6 标准发布前出台的,而 ES6 引入了自己的私有成员写法#propName。因此建议不使用private,改用 ES6 的写法,获得真正意义的私有成员。
1 | class A { |
上面示例中,采用了 ES6 的私有成员写法(属性名前加#),TypeScript 就正确识别了实例对象没有属性x,从而报错。
构造方法也可以是私有的,这就直接防止了使用new命令生成实例对象,只能在类的内部创建实例对象。
这时一般会有一个静态方法,充当工厂函数,强制所有实例都通过该方法生成。
1 | class Singleton { |
11.5.3 protected
protected修饰符表示该成员是保护成员,只能在类的内部使用该成员,实例无法使用该成员,但是子类内部可以使用。
1 | class A { |
子类不仅可以拿到父类的保护成员,还可以定义同名成员。
1 | class A { |
上面示例中,子类B定义了父类A的同名成员x,并且父类的x是保护成员,子类将其改成了公开成员。B类的x属性前面没有修饰符,等同于修饰符是public,外界可以读取这个属性。
在类的外部,实例对象不能读取保护成员,但是在类的内部可以。
1 | class A { |
11.5.4 实例属性的简写形式
实际开发中,很多实例属性的值,是通过构造方法传入的。
1 | class Point { |
这样的写法等于对同一个属性要声明两次类型,一次在类的头部,另一次在构造方法的参数里面。这有些累赘,TypeScript 就提供了一种简写形式。
1 | class Point { |
上面示例中,构造方法的参数x前面有public修饰符,这时 TypeScript 就会自动声明一个公开属性x,不必在构造方法里面写任何代码,同时还会设置x的值为构造方法的参数值。注意,这里的public不能省略。
除了public修饰符,构造方法的参数名只要有private、protected、readonly修饰符,都会自动声明对应修饰符的实例属性。
1 | class A { |
readonly还可以与其他三个可访问性修饰符,一起使用。
1 | class A { |
11.6 静态成员
类的内部可以使用staic关键字,定义静态成员。
静态成员是只能通过类本身使用的成员,不能通过实例对象使用。
1 | class MyClass { |
static关键字前面可以使用 public、private、protected 修饰符。
1 | class MyClass { |
上面示例中,静态属性x前面有private修饰符,表示只能在MyClass内部使用,如果在外部调用这个属性就会报错。
静态私有属性也可以用 ES6 语法的#前缀表示,上面示例可以改写如下。
1 | class MyClass { |
public和protected的静态成员可以被继承。
1 | class A { |
11.7 泛型类
类也可以写成泛型,使用类型参数。
1 | class Box<Type> { |
上面示例中,类Box有类型参数Type,因此属于泛型类。新建实例时,变量的类型声明需要带有类型参数的值,不过本例等号左边的Box<**string**>可以省略不写,因为可以从等号右边推断得到。
注意,静态成员不能使用泛型的类型参数。
1 | class Box<Type> { |
上面示例中,静态属性defaultContents的类型写成类型参数Type会报错。因为这意味着调用时必须给出类型参数(即写成Box<**string**>.defaultContents),并且类型参数发生变化,这个属性也会跟着变,这并不是好的做法。
11.8 抽象类,抽象成员
TypeScript 允许在类的定义前面,加上关键字abstract,表示该类不能被实例化,只能当作其他类的模板。这种类就叫做“抽象类”(abastract class)。
1 | abstract class A { |
抽象类只能当作基类使用,用来在它的基础上定义子类。
1 | abstract class A { |
抽象类的子类也可以是抽象类,也就是说,抽象类可以继承其他抽象类。
1 | abstract class A { |
抽象类的内部可以有已经实现好的属性和方法,也可以有还未实现的属性和方法。后者就叫做“抽象成员”(abstract member),即属性名和方法名有abstract关键字,表示该方法需要子类实现。如果子类没有实现抽象成员,就会报错。
1 | abstract class A { |
上面示例中,抽象类A定义了抽象属性foo,子类B必须实现这个属性,否则会报错。
如果抽象类的属性前面加上abstract,就表明子类必须给出该方法的实现。
1 | abstract class A { |
这里有几个注意点。
(1)抽象成员只能存在于抽象类,不能存在于普通类。
(2)抽象成员不能有具体实现的代码。也就是说,已经实现好的成员前面不能加abstract关键字。
(3)抽象成员前也不能有private修饰符,否则无法在子类中实现该成员。
(4)一个子类最多只能继承一个抽象类。
总之,抽象类的作用是,确保各种相关的子类都拥有跟基类相同的接口,可以看作是模板。其中的抽象成员都是必须由子类实现的成员,非抽象成员则表示基类已经实现的、由所有子类共享的成员。
11.9 this 问题
类的方法经常用到this关键字,它表示该方法当前所在的对象。
1 | class A { |
上面示例中,变量a和b的getName()是同一个方法,但是执行结果不一样,原因就是它们内部的this指向不一样的对象。如果getName()在变量a上运行,this指向a;如果在b上运行,this指向b。
有些场合需要给出this类型,但是 JavaScript 函数通常不带有this参数,这时TypeScript 允许函数增加一个名为this的参数,放在参数列表的第一位,用来描述函数内部的this关键字的类型。
1 | // 编译前 |
上面示例中,函数fn()的第一个参数是this,用来声明函数内部的this的类型。编译时,TypeScript 一旦发现函数的第一个参数名为this,则会去除这个参数,即编译结果不会带有该参数。
1 | class A { |
上面示例中,类A的getName()添加了this参数,如果直接调用这个方法,this的类型就会跟声明的类型不一致,从而报错。
this参数的类型可以声明为各种对象。
1 | function foo( |
TypeScript 提供了一个noImplicitThis编译选项。如果打开了这个设置项,如果this的值推断为any类型,就会报错。
1 | // noImplicitThis 打开 |
上面示例中,getAreaFunction()方法返回一个函数,这个函数里面用到了this,但是这个this跟Rectangle这个类没关系,它的类型推断为any,所以就报错了。
在类的内部,this本身也可以当作类型使用,表示当前类的实例对象。
1 | class Box { |
注意,this类型不允许应用于静态成员。
1 | class A { |
有些方法返回一个布尔值,表示当前的this是否属于某种类型。这时,这些方法的返回值类型可以写成this is Type的形式,其中用到了is运算符。
1 | class FileSystemObject { |
上面示例中,两个方法的返回值类型都是布尔值,写成this is Type的形式,可以精确表示返回值。
十二、TypeScript 泛型
12.1 简介
有些时候,函数返回值的类型与参数类型是相关的。
1 | function getFirst(arr) { |
为了解决这个问题,TypeScript 就引入了“泛型”(generics)。泛型的特点就是带有“类型参数”(type parameter)。
1 | function getFirst<T>(arr:T[]):T { |
上面示例中,函数getFirst()的函数名后面尖括号的部分
函数调用时,需要提供类型参数。
1 | getFirst<number>([1, 2, 3]) |
为了方便,函数调用时,往往省略不写类型参数的值,让 TypeScript 自己推断。
有些复杂的使用场景,TypeScript 可能推断不出类型参数的值,这时就必须显式给出了。
1 | function comb<T>(arr1:T[], arr2:T[]):T[] { |
类型参数的名字,可以随便取,但是必须为合法的标识符。习惯上,类型参数的第一个字符往往采用大写字母。一般会使用T(type 的第一个字母)作为类型参数的名字。如果有多个类型参数,则使用 T 后面的 U、V 等字母命名,各个参数之间使用逗号(“,”)分隔。
1 | function map<T, U>( |
总之,泛型可以理解成一段类型逻辑,需要类型参数来表达。有了类型参数以后,可以在输入类型与输出类型之间,建立一一对应关系。
12.2 泛型的写法
泛型主要用在四个场合:函数、接口、类和别名。
12.2.1 函数的泛型写法
function关键字定义的泛型函数,类型参数放在尖括号中,写在函数名后面。
1 | function id<T>(arg:T):T { |
那么对于变量形式定义的函数,泛型有下面两种写法。
1 | // 写法一 |
12.2.2 接口的泛型写法
interface 也可以采用泛型的写法。
1 | interface Box<Type> { |
1 | interface Comparator<T> { |
泛型接口还有第二种写法。
1 | interface Fn { |
此外,第二种写法还有一个差异之处。那就是它的类型参数定义在某个方法之中,其他属性和方法不能使用该类型参数。前面的第一种写法,类型参数定义在整个接口,接口内部的所有属性和方法都可以使用该类型参数。
12.2.3 类的泛型写法
泛型类的类型参数写在类名后面。
1 | class Pair<K, V> { |
下面是继承泛型类的例子。
1 | class A<T> { |
类A有一个类型参数T,使用时必须给出T的类型,所以类B继承时要写成A<**any**>。
泛型也可以用在类表达式。
1 | const Container = class<T> { |
1 | class C<NumType> { |
JavaScript 的类本质上是一个构造函数,因此也可以把泛型类写成构造函数。
1 | type MyClass<T> = new (...args: any[]) => T; |
注意,泛型类描述的是类的实例,不包括静态属性和静态方法,因为这两者定义在类的本身。因此,它们不能引用类型参数。
1 | class C<T> { |
12.2.4 类型别名的泛型写法
type 命令定义的类型别名,也可以使用泛型。
1 | type Nullable<T> = T | undefined | null; |
1 | //下面是另一个例子。 |
12.3 类型参数的默认值
类型参数可以设置默认值。使用时,如果没有给出类型参数的值,就会使用默认值。
1 | function getFirst<T = string>( |
上面示例中,T = string表示类型参数的默认值是string。调用getFirst()时,如果不给出T的值,TypeScript 就认为T等于string。
但是,因为 TypeScript 会从实际参数推断出T的值,从而覆盖掉默认值,所以下面的代码不会报错。
1 | getFirst([1, 2, 3]) // 正确 |
类型参数的默认值,往往用在类中。
1 | class Generic<T = string> { |
新建Generic的实例g时,没有给出类型参数T的值,所以T就等于string。因此,向add()方法传入一个数值会报错,传入字符串就不会。
一旦类型参数有默认值,就表示它是可选参数。如果有多个类型参数,可选参数必须在必选参数之后。
12.4 数组的泛型表示
数组类型有一种表示方法是Array<**T**>。这就是泛型的写法,Array是 TypeScript 原生的一个类型接口,T是它的类型参数。声明数组时,需要提供T的值。
1 | let arr:Array<number> = [1, 2, 3]; |
同样的,如果数组成员都是字符串,那么类型就写成Array<**string**>。事实上,在TypeScript 内部,数组类型的另一种写法number[]、string[],只是Array<**number**>、Array<**string**>的简写形式。
在 TypeScript 内部,Array是一个泛型接口,类型定义基本是下面的样子。
1 | interface Array<Type> { |
其他的 TypeScript 内部数据结构,比如Map、Set和Promise,其实也是泛型接口,完整的写法是Map<**K, V**>、Set<**T**>和Promise<**T**>。
TypeScript 默认还提供一个ReadonlyArray<**T**>接口,表示只读数组。
1 | function doStuff( |
如果不希望函数内部改动参数数组,就可以将该参数数组声明为ReadonlyArray<**T**>类型。
12.5 类型参数的约束条件
很多类型参数并不是无限制的,对于传入的类型存在约束条件。
1 | function comp<Type>(a:Type, b:Type) { |
TypeScript 提供了一种语法,允许在类型参数上面写明约束条件,如果不满足条件,编译时就会报错。这样也可以有良好的语义,对类型参数进行说明。
1 | function comp<T extends { length: number }>( |
上面示例中,T extends { length: number }就是约束条件,表示类型参数 T 必须满足{ length: number },否则就会报错。
类型参数的约束条件采用下面的形式。
1 | <TypeParameter extends ConstraintType> |
上面语法中,TypeParameter表示类型参数,extends是关键字,这是必须的,ConstraintType表示类型参数要满足的条件,即类型参数应该是ConstraintType的子类型。
类型参数可以同时设置约束条件和默认值,前提是默认值必须满足约束条件。
1 | type Fn<A extends string, B extends string = 'world'> |
泛型本质上是一个类型函数,通过输入参数,获得结果,两者是一一对应关系。
如果有多个类型参数,一个类型参数的约束条件,可以引用其他参数。
1 | <T, U extends T> |
但是,约束条件不能引用类型参数自身。
12.6 使用注意点
(1)尽量少用泛型。
泛型虽然灵活,但是会加大代码的复杂性,使其变得难读难写。一般来说,只要使用了泛型,类型声明通常都不太易读,容易写得很复杂。因此,可以不用泛型就不要用。
(2)类型参数越少越好。
多一个类型参数,多一道替换步骤,加大复杂性。因此,类型参数越少越好。
(3)类型参数需要出现两次。
如果类型参数在定义后只出现一次,那么很可能是不必要的。
只有当类型参数用到两次或两次以上,才是泛型的适用场合。
(4)泛型可以嵌套。
类型参数可以是另一个泛型。
十三、TypeScript 的 Enum 类型
Enum 是 TypeScript 新增的一种数据结构和类型,中文译为“枚举”。
13.1 简介
实际开发中,经常需要定义一组相关的常量。
TypeScript 就设计了 Enum 结构,用来将相关常量放在一个容器里面,方便使用。
1 | enum Color { |
使用时,调用 Enum 的某个成员,与调用对象属性的写法一样,可以使用点运算符,也可以使用方括号运算符。
1 | let c = Color.Green; // 1 |
Enum 结构本身也是一种类型。比如,上例的变量c等于1,它的类型可以是Color,也可以是number。
1 | let c:Color = Color.Green; // 正确 |
Enum 结构的特别之处在于,它既是一种类型,也是一个值。绝大多数 TypeScript语法都是类型语法,编译后会全部去除,但是 Enum 结构是一个值,编译后会变成JavaScript 对象,留在代码中。
1 | // 编译前 |
上面示例是 Enum 结构编译前后的对比。
由于 TypeScript 的定位是 JavaScript 语言的类型增强,所以官方建议谨慎使用Enum 结构,因为它不仅仅是类型,还会为编译后的代码加入一个对象。
Enum 结构比较适合的场景是,成员的值不重要,名字更重要,从而增加代码的可读性和可维护性。
1 | enum Operator { |
Enum 作为类型有一个缺点,就是输入任何数值都不报错。
另外,由于 Enum 结构编译后是一个对象,所以不能有与它同名的变量(包括对象、函数、类等)。
1 | enum Color { |
很大程度上,Enum 结构可以被对象的as const断言替代。
1 | enum Foo { |
13.2 Enum 成员的值
Enum 成员默认不必赋值,系统会从零开始逐一递增,按照顺序为每个成员赋值。
但是,也可以为 Enum 成员显式赋值。
1 | enum Color { |
成员的值可以是任意数值,但不能是大整数(Bigint)。
成员的值甚至可以相同。
如果只设定第一个成员的值,后面成员的值就会从这个值开始递增。
1 | enum Color { |
Enum 成员的值也可以使用计算式。
1 | enum Permission { |
Enum 成员值都是只读的,不能重新赋值。
为了让这一点更醒目,通常会在 enum 关键字前面加上const修饰,表示这是常量,不能再次赋值。
1 | const enum Color { |
加上const还有一个好处,就是编译为 JavaScript 代码后,代码中 Enum 成员会被替换成对应的值,这样能提高性能表现。
1 | const enum Color { |
如果希望加上const关键词后,运行时还能访问 Enum 结构(即编译后依然将Enum 转成对象),需要在编译时打开preserveConstEnums编译选项。
13.3 同名 Enum 的合并
多个同名的 Enum 结构会自动合并。
1 | enum Foo { |
Enum 结构合并时,只允许其中一个的首成员省略初始值,否则报错。
同名 Enum 合并时,不能有同名成员,否则报错。
同名 Enum 合并的另一个限制是,所有定义必须同为 const 枚举或者非 const 枚举,不允许混合使用。
同名 Enum 的合并,最大用处就是补充外部定义的 Enum 结构。
13.4 字符串 Enum
Enum 成员的值除了设为数值,还可以设为字符串。也就是说,Enum 也可以用作一组相关字符串的集合。
1 | enum Direction { |
注意,字符串枚举的所有成员值,都必须显式设置。如果没有设置,成员值默认为数值,且位置必须在字符串成员之前。
1 | enum Foo { |
Enum 成员可以是字符串和数值混合赋值。
1 | enum Enum { |
除了数值和字符串,Enum 成员不允许使用其他值(比如 Symbol 值)。
变量类型如果是字符串 Enum,就不能再赋值为字符串,这跟数值 Enum 不一样。
1 | enum MyEnum { |
由于这个原因,如果函数的参数类型是字符串 Enum,传参时就不能直接传入字符串,而要传入 Enum 成员。
1 | enum MyEnum { |
所以,字符串 Enum 作为一种类型,有限定函数参数的作用。
1 | const enum MediaTypes { |
字符串 Enum 可以使用联合类型(union)代替。
1 | function move( |
注意,字符串 Enum 的成员值,不能使用表达式赋值。
13.5 keyof 运算符
keyof 运算符可以取出 Enum 结构的所有成员名,作为联合类型返回。
1 | enum MyEnum { |
注意,这里的typeof是必需的,否则keyof MyEnum相当于keyof number。
1 | type Foo = keyof MyEnum; |
上面示例中,类型Foo等于类型number的所有原生属性名组成的联合类型。
这是因为 Enum 作为类型,本质上属于number或string的一种变体,而typeof MyEnum会将MyEnum当作一个值处理,从而先其转为对象类型,就可以再用keyof运算符返回该对象的所有属性名。
如果要返回 Enum 所有的成员值,可以使用in运算符。
1 | enum MyEnum { |
13.6 反向映射
数值 Enum 存在反向映射,即可以通过成员值获得成员名。
1 | enum Weekdays { |
这是因为 TypeScript 会将上面的 Enum 结构,编译成下面的 JavaScript 代码。
1 | var Weekdays; |
上面代码中,实际进行了两组赋值,以第一个成员为例。
1 | Weekdays[ |
注意,这种情况只发生在数值 Enum,对于字符串 Enum,不存在反向映射。这是因为字符串 Enum 编译后只有一组赋值。
1 | enum MyEnum { |
十四、TypeScript 的类型断言
14.1 简介
对于没有类型声明的值,TypeScript 会进行类型推断,很多时候得到的结果,未必是开发者想要的。
1 | type T = 'a'|'b'|'c'; |
上面示例中,最后一行报错,原因是 TypeScript 推断变量foo的类型是string,而变量bar的类型是’a’|’b’|’c’,前者是后者的父类型。父类型不能赋值给子类型,所以就报错了。
TypeScript 提供了“类型断言”这样一种手段,允许开发者在代码中“断言”某个值的类型,告诉编译器此处的值是什么类型。TypeScript 一旦发现存在类型断言,就不再对该值进行类型推断,而是直接采用断言给出的类型。
这种做法的实质是,允许开发者在某个位置“绕过”编译器的类型推断,让本来通不过类型检查的代码能够通过,避免编译器报错。这样虽然削弱了 TypeScript 类型系统的严格性,但是为开发者带来了方便,毕竟开发者比编译器更了解自己的代码。
1 | type T = 'a'|'b'|'c'; |
最后一行的foo as T表示告诉编译器,变量foo的类型断言为T,所以这一行不再需要类型推断了,编译器直接把foo的类型当作T,就不会报错了。
总之,类型断言并不是真的改变一个值的类型,而是提示编译器,应该如何处理这个值。
类型断言有两种语法。
1 | // 语法一:<类型>值 |
上面两种语法是等价的,value表示值,Type表示类型。早期只有语法一,后来因为 TypeScript 开始支持 React 的 JSX 语法(尖括号表示 HTML 元素),为了避免两者冲突,就引入了语法二。目前,推荐使用语法二。
1 | // 语法一 |
下面看一个例子。
1 | // 报错 |
上面示例中,等号右侧是一个对象字面量,多出了属性y,导致报错。解决方法就是使用类型断言,可以用两种不同的断言。
1 | // 正确 |
上面示例中,两种类型断言都是正确的。第一种断言将类型改成与等号左边一致,第二种断言使得等号右边的类型是左边类型的子类型,子类型可以赋值给父类型,同时因为存在类型断言,就没有严格字面量检查了,所以不报错。
下面是一个网页编程的实际例子。
1 | const username = document.getElementById('username'); |
注意,上例的类型断言的圆括号是必需的,否则username会被断言成HTMLInputElement.value,从而报错。
类型断言不应滥用,因为它改变了 TypeScript 的类型检查,很可能埋下错误的隐患。
1 | const data:object = { |
上面示例中,变量data是一个对象,没有length属性。但是通过类型断言,可以将它的类型断言为数组,这样使用length属性就能通过类型检查。但是,编译后的代码在运行时依然会报错,所以类型断言可以让错误的代码通过编译。
类型断言的一大用处是,指定 unknown 类型的变量的具体类型。
1 | const value:unknown = 'Hello World'; |
上面示例中,unknown 类型的变量value不能直接赋值给其他类型的变量,但是可以将它断言为其他类型,这样就可以赋值给别的变量了。
另外,类型断言也适合指定联合类型的值的具体类型。
1 | const s1:number|string = 'hello'; |
14.2 类型断言的条件
类型断言并不意味着,可以把某个值断言为任意类型。
1 | const n = 1; |
类型断言的使用前提是,值的实际类型与断言的类型必须满足一个条件。
1 | expr as T |
上面代码中,expr是实际的值,T是类型断言,它们必须满足下面的条件:expr是T的子类型,或者T是expr的子类型。
也就是说,类型断言要求实际的类型与断言的类型兼容,实际类型可以断言为一个更加宽泛的类型(父类型),也可以断言为一个更加精确的类型(子类型),但不能断言为一个完全无关的类型。
但是,如果真的要断言成一个完全无关的类型,也是可以做到的。那就是连续进行两次类型断言,先断言成 unknown 类型或 any 类型,然后再断言为目标类型。因为any类型和unknown类型是所有其他类型的父类型,所以可以作为两种完全无关的类型的中介。
1 | // 或者写成 <T><unknown>expr |
14.3 as const 断言
如果没有声明变量类型,let 命令声明的变量,会被类型推断为 TypeScript 内置的基本类型之一;const 命令声明的变量,则被推断为值类型常量。
1 | // 类型推断为基本类型 string |
上面示例中,变量s1的类型被推断为string,变量s2的类型推断为值类型JavaScript。后者是前者的子类型,相当于 const 命令有更强的限定作用,可以缩小变量的类型范围。
有些时候,let 变量会出现一些意想不到的报错,变更成 const 变量就能消除报错。
1 | let s = 'JavaScript'; |
上面示例中,最后一行报错,原因是函数setLang()的参数language类型是Lang,这是一个联合类型。但是,传入的字符串s的类型被推断为string,属于Lang的父类型。父类型不能替代子类型,导致报错。
一种解决方法就是把 let 命令改成 const 命令。
1 | const s = 'JavaScript'; |
另一种解决方法是使用类型断言。TypeScript 提供了一种特殊的类型断言as const,用于告诉编译器,推断类型时,可以将这个值推断为常量,即把 let 变量断言为 const 变量,从而把内置的基本类型变更为值类型。
1 | let s = 'JavaScript' as const; |
使用了as const断言以后,let 变量就不能再改变值了。
1 | let s = 'JavaScript' as const; |
注意,as const断言只能用于字面量,不能用于变量。
1 | let s = 'JavaScript'; |
另外,as const也不能用于表达式。
1 | let s = ('Java' + 'Script') as const; // 报错 |
as const也可以写成前置的形式。
1 | // 后置形式 |
as const断言可以用于整个对象,也可以用于对象的单个属性,这时它的类型缩小效果是不一样的。
1 | const v1 = { |
上面示例中,第二种写法是对属性x缩小类型,第三种写法是对整个对象缩小类型。
总之,as const会将字面量的类型断言为不可变类型,缩小成 TypeScript 允许的最小类型。
下面是数组的例子。
1 | // a1 的类型推断为 number[] |
由于as const会将数组变成只读元组,所以很适合用于函数的 rest 参数。
1 | function add(x:number, y:number) { |
上面示例中,变量nums的类型推断为number[],导致使用扩展运算符…传入函数add()会报错,因为add()只能接受两个参数,而…nums并不能保证参数的个数。
事实上,对于固定参数个数的函数,如果传入的参数包含扩展运算符,那么扩展运算符只能用于元组。只有当函数定义使用了 rest 参数,扩展运算符才能用于数组。
解决方法就是使用as const断言,将数组变成元组。
1 | const nums = [1, 2] as const; |
Enum 成员也可以使用as const断言。
1 | enum Foo { |
上面示例中,如果不使用as const断言,变量e1的类型被推断为整个 Enum 类型;使用了as const断言以后,变量e2的类型被推断为 Enum 的某个成员,这意味着它不能变更为其他成员。
14.4 非空断言
对于那些可能为空的变量(即可能等于undefined或null),TypeScript 提供了非空断言,保证这些变量不会为空,写法是在变量名后面加上感叹号!。
1 | function f(x?:number|null) { |
非空断言在实际编程中很有用,有时可以省去一些额外的判断。
1 | const root = document.getElementById('root'); |
上面示例中,getElementById()有可能返回空值null,即变量root可能为空,这时对它调用addEventListener()方法就会报错,通不过编译。但是,开发者如果可以确认root元素肯定会在网页中存在,这时就可以使用非空断言。
1 | const root = document.getElementById('root')!; |
不过,非空断言会造成安全隐患,只有在确定一个表达式的值不为空时才能使用。比较保险的做法还是手动检查一下是否为空。
1 | const root = document.getElementById('root'); |
非空断言还可以用于赋值断言。TypeScript 有一个编译设置,要求类的属性必须初始化(即有初始值),如果不对属性赋值就会报错。
1 | class Point { |
上面示例中,属性x和y会报错,因为 TypeScript 认为它们没有初始化。
这时就可以使用非空断言,表示这两个属性肯定会有值,这样就不会报错了。
1 | class Point { |
另外,非空断言只有在打开编译选项strictNullChecks时才有意义。如果不打开选项,编译器就不会检查某个变量是否可能为undefined或null。
14.5 断言函数
断言函数是一种特殊函数,用于保证函数参数符合某种类型。如果函数参数达不到要求,就会抛出错误,中断程序执行;如果达到要求,就不进行任何操作,让代码按照正常流程运行。
1 | function isString(value) { |
断言函数的类型可以写成下面这样。
1 | function isString(value:unknown):void { |
上面代码中,函数参数value的类型是unknown,返回值类型是void,即没有返回值。可以看到,单单从这样的类型声明,很难看出isString()是一个断言函数。
为了更清晰地表达断言函数,TypeScript 3.7 引入了新的类型写法。
1 | function isString(value:unknown):asserts value is string { |
上面示例中,函数isString()的返回值类型写成asserts value is string,其中asserts和is都是关键词,value是函数的参数名,string是函数参数的预期类型。它的意思是,该函数用来断言参数value的类型是string,如果达不到要求,程序就会在这里中断。
使用了断言函数的新写法以后,TypeScript 就会自动识别,只要执行了该函数,对应的变量都为断言的类型。
注意,函数返回值的断言写法,只是用来更清晰地表达函数意图,真正的检查是需要开发者自己部署的。而且,如果内部的检查与断言不一致,TypeScript 也不会报错。
1 | function isString(value:unknown):asserts value is string { |
上面示例中,函数的断言是参数value类型为字符串,但是实际上,内部检查的却是它是否为数值,如果不是就抛错。这段代码能够正常通过编译,表示 TypeScript并不会检查断言与实际的类型检查是否一致。
另外,断言函数的asserts语句等同于void类型,所以如果返回除了undefined和null以外的值,都会报错。
1 | function isString(value:unknown):asserts value is string { |
如果要断言参数非空,可以使用工具类型NonNullable<**T**>。
1 | function assertIsDefined<T>( |
如果要将断言函数用于函数表达式,可以采用下面的写法。
1 | // 写法一 |
注意,断言函数与类型保护函数(type guard)是两种不同的函数。它们的区别是,断言函数不返回值,而类型保护函数总是返回一个布尔值。
1 | function isString( |
上面示例就是一个类型保护函数isString(),作用是检查参数value是否为字符串。如果是的,返回true,否则返回false。该函数的返回值类型是value is string,其中的is是一个类型运算符,如果左侧的值符合右侧的类型,则返回true,否则返回false。
如果要断言某个参数保证为真(即不等于false、undefined和null),TypeScript 提供了断言函数的一种简写形式。
1 | function assert(x:unknown):asserts x { |
上面示例中,函数assert()的断言部分,asserts x省略了谓语和宾语,表示参数x保证为真(true)。
同样的,参数为真的实际检查需要开发者自己实现。
1 | function assert(x:unknown):asserts x { |
这种断言函数的简写形式,通常用来检查某个操作是否成功。
1 | type Person = { |
上面示例中,只有loadPerson()返回结果为真(即操作成功),assert()才不会报错。
十五、TypeScript 模块
15.1 简介
任何包含 import 或 export 语句的文件,就是一个模块(module)。相应地,如果文件不包含 export 语句,就是一个全局的脚本文件。
模块本身就是一个作用域,不属于全局作用域。模块内部的变量、函数、类只在内部可见,对于模块外部是不可见的。暴露给外部的接口,必须用 export 命令声明;如果其他文件要使用模块的接口,必须用 import 命令来输入。
如果一个文件不包含 export 语句,但是希望把它当作一个模块(即内部变量对外不可见),可以在脚本头部添加一行语句。
1 | export {}; |
上面这行语句不产生任何实际作用,但会让当前文件被当作模块处理,所有它的代码都变成了内部代码。
TypeScript 模块除了支持所有 ES 模块的语法,特别之处在于允许输出和输入类型。
1 | export type Bool = true | false; |
假定上面的模块文件为a.ts,另一个文件b.ts就可以使用 import 语句,输入这个类型。
1 | import { Bool } from './a'; |
TypeScript 允许加载模块时,省略模块文件的后缀名,它会自动定位。
编译时,可以两个脚本同时编译。
1 | $ tsc a.ts b.ts |
也可以只编译b.ts,因为它是入口脚本,tsc 会自动编译它依赖的所有脚本。
1 | $ tsc b.ts |
上面命令发现b.ts依赖a.js,就会自动寻找a.ts,也将其同时编译,因此编译产物还是a.js和b.js两个文件。
如果想将a.ts和b.ts编译成一个文件,可以使用–outFile参数。
1 | $ tsc --outFile result.js b.ts |
15.2 import type 语句
import 在一条语句中,可以同时输入类型和正常接口。
1 | // a.ts |
这样很不利于区分类型和正常接口,容易造成混淆。为了解决这个问题,TypeScript引入了两个解决方法。
第一个方法是在 import 语句输入的类型前面加上type关键字。
1 | import { type A, a } from './a'; |
上面示例中,import 语句输入的类型A前面有type关键字,表示这是一个类型。
第二个方法是使用 import type 语句,这个语句只能输入类型,不能输入正常接口。
1 | // 正确 |
import type 语句也可以输入默认类型。
1 | import type DefaultType from 'moduleA'; |
import type 在一个名称空间下,输入所有类型的写法如下。
1 | import type * as TypeNS from 'moduleA'; |
同样的,export 语句也有两种方法,表示输出的是类型。
1 | type A = 'a'; |
上面示例中,方法一是使用type关键字作为前缀,表示输出的是类型;方法二是使用 export type 语句,表示整行输出的都是类型。
下面是 export type 将一个类作为类型输出的例子。
1 | class Point { |
上面示例中,由于使用了 export type 语句,输出的并不是 Point 这个类,而是Point 代表的实例类型。输入时,只能作为类型输入。
1 | import type { Point } from './module'; |
15.3 importsNotUsedAsValues
TypeScript 提供了importsNotUsedAsValues编译设置项,有三个可能的值。
(1)remove:这是默认值,自动删除输入类型的 import 语句。
(2)preserve:保留输入类型的 import 语句。
(3)error:保留输入类型的 import 语句(与preserve相同),但是必须写成import type 的形式,否则报错。
下面是一个输入类型的 import 语句。
1 | import { TypeA } from './a'; |
上面示例中,TypeA是一个类型。
remove的编译结果会将该语句删掉。
preserve的编译结果会保留该语句,但会把删掉类型的部分。
1 | import './a'; |
上面示例中,编译后的 import 语句不从a.js输入任何接口,但是会引发a.js的执行,因此会保留a.js里面的副作用。
error的结果与preserve相同,但是编译过程会报错,因为输入类型的 import 语句必须写成 import type 的形式。原始语句改成下面的形式,就不会报错。
1 | import type { TypeA } from './a.js'; |
15.4 CommonJS 模块
CommonJS 是 Node.js 的专用模块格式,与 ES 模块格式不兼容。
TypeScript 使用import =语句输入 CommonJS 模块。
1 | import fs = require('fs'); |
上面示例中,使用import =语句和require()命令输入了一个 CommonJS 模块。模块本身的用法跟 Node.js 是一样的。
除了使用import =语句,TypeScript 还允许使用import * as [接口名] from “模块文件”输入 CommonJS 模块。
1 | import * as fs from 'fs'; |
TypeScript 使用export =语句,输出 CommonJS 模块的对象,等同于 CommonJS的module.exports对象。
1 | let obj = { foo: 123 }; |
export = 语句输出的对象,只能使用import =语句加载。
1 | import obj = require('./a'); |
15.5 模块定位
模块定位(module resolution)指的是确定 import 语句和 export 语句里面的模块文件位置。
1 | import { TypeA } from './a'; |
模块定位有两种方法,一种称为 Classic 方法,另一种称为 Node 方法。可以使用编译参数moduleResolution,指定使用哪一种方法。
没有指定定位方法时,就看原始脚本采用什么模块格式。如果模块格式是CommonJS(即编译时指定–module commonjs),那么模块定位采用 Node 方法,否则采用 Classic 方法(模块格式为 es2015、 esnext、amd, system, umd 等等)。
15.5.1 相对模块,非相对模块
加载模块时,目标模块分为相对模块(relative import)和非相对模块两种(nonrelative import)。
相对模块指的是路径以/、./、../开头的模块。下面 import 语句加载的模块,都是相对模块。
import Entry from “./components/Entry”;
import { DefaultHeaders } from “../constants/http”;
import “/mod”;
非相对模块指的是不带有路径信息的模块。下面 import 语句加载的模块,都是非相对模块。
import * as $ from “jquery”;
import { Component } from “@angular/core”;
15.5.2 Classic 方法
Classic 方法以当前脚本的路径作为“基准路径”,计算相对模块的位置。比如,脚本a.ts里面有一行代码import { b } from “./b”,那么 TypeScript 就会在a.ts
所在的目录,查找b.ts和b.d.ts。
至于非相对模块,也是以当前脚本的路径作为起点,一层层查找上级目录。比如,脚本a.ts里面有一行代码import { b } from “b”,那么就会查找b.ts和b.d.ts。
15.5.3 Node 方法
Node 方法就是模拟 Node.js 的模块加载方法。
相对模块依然是以当前脚本的路径作为“基准路径”。比如,脚本文件a.ts里面有一行代码let x = require(“./b”);,TypeScript 按照以下顺序查找。
- 当前目录是否包含b.ts、b.tsx、b.d.ts。
- 当前目录是否有子目录b,该子目录是否存在文件package.json,该文件的
types字段是否指定了入口文件,如果是的就加载该文件。 - 当前目录的子目录b是否包含index.ts、index.tsx、index.d.ts。
非相对模块则是以当前脚本的路径作为起点,逐级向上层目录查找是否存在子目录node_modules。比如,脚本文件a.js有一行let x = require(“b”);,
TypeScript 按照以下顺序进行查找。
- 当前目录的子目录node_modules是否包含b.ts、b.tsx、b.d.ts。
- 当前目录的子目录node_modules,是否存在文件package.json,该文件的types字段是否指定了入口文件,如果是的就加载该文件。
- 当前目录的子目录node_modules里面,是否包含子目录@types,在该目录中查找文件b.d.ts。
- 当前目录的子目录node_modules里面,是否包含子目录b,在该目录中查找index.ts、index.tsx、index.d.ts。
- 进入上一层目录,重复上面4步,直到找到为止。
15.5.4 路径映射
TypeScript 允许开发者在tsconfig.json文件里面,手动指定脚本模块的路径。
(1)baseUrl
baseUrl字段可以手动指定脚本模块的基准目录。
1 | { |
(2)paths
paths字段指定非相对路径的模块与实际脚本的映射。
1 | { |
(3)rootDirs
rootDirs字段指定模块定位时必须查找的其他目录。
1 | { |
15.5.5 tsc 的–traceResolution参数
由于模块定位的过程很复杂,tsc 命令有一个–traceResolution参数,能够在编译时在命令行显示模块定位的每一步。
1 | $ tsc --traceResolution |
15.5.6 tsc 的–noResolve参数
tsc 命令的–noResolve参数,表示模块定位时,只考虑在命令行传入的模块。
举例来说,app.ts包含如下两行代码。
1 | import * as A from "moduleA"; |
使用下面的命令进行编译。
1 | $ tsc app.ts moduleA.ts --noResolve |
十六、TypeScript namespace
namespace 是一种将相关代码组织在一起的方式,中文译为“命名空间”。
16.1 基本用法
namespace 用来建立一个容器,内部的所有变量和函数,都必须在这个容器里面使用。
1 | namespace Utils { |
如果要在命名空间以外使用内部成员,就必须为该成员加上export前缀,表示对外输出该成员。
1 | namespace Utility { |
编译出来的 JavaScript 代码如下。
1 | var Utility; |
上面代码中,命名空间Utility变成了 JavaScript 的一个对象,凡是export的内部成员,都成了该对象的属性。
这就是说,namespace 会变成一个值,保留在编译后的代码中。这一点要小心,它不是纯的类型代码。
namespace 内部还可以使用import命令输入外部成员,相当于为外部成员起别名。当外部成员的名字比较长时,别名能够简化代码。
1 | namespace Utils { |
import命令也可以在 namespace 外部,指定别名。
1 | namespace Shapes { |
namespace 可以嵌套。
1 | namespace Utils { |
注意,如果要在外部使用Messaging,必须在它前面加上export命令。
使用嵌套的命名空间,必须从最外层开始引用,比如Utils.Messaging.log()。
namespace 不仅可以包含实义代码,还可以包括类型代码。
1 | namespace N { |
namespace 与模块的作用是一致的,都是把相关代码组织在一起,对外输出接口。区别是一个文件只能有一个模块,但可以有多个 namespace。由于模块可以取代namespace,而且是 JavaScript 的标准语法,还不需要编译转换,所以建议总是使用模块,替代 namespace。
如果 namespace 代码放在一个单独的文件里,那么引入这个文件需要使用三斜杠的语法。
1 | /// <reference path = "SomeFileName.ts" /> |
16.2 namespace 的输出
namespace 本身也可以使用export命令输出,供其他文件使用。
1 | // shapes.ts |
其他脚本文件使用import命令,加载这个命名空间。
1 | // 写法一 |
不过,更好的方法还是建议使用模块,采用模块的输出和输入。
1 | // shapes.ts |
16.3 namespace 的合并
多个同名的 namespace 会自动合并,这一点跟 interface 一样。
1 | namespace Animals { |
这样做的目的是,如果同名的命名空间分布在不同的文件中,TypeScript 最终会它们合并在一起。这样就比较方便扩展别人的代码。
合并命名空间时,命名空间中的非export的成员不会被合并,但是它们只能在各自的命名空间中使用。
1 | namespace N { |
命名空间还可以跟同名函数合并,但是要求同名函数必须在命名空间之前声明。这样做是为了确保先创建出一个函数对象,然后同名的命名空间就相当于给这个函数对象添加额外的属性。
1 | function f() { |
命名空间也能与同名 class 合并,同样要求class 必须在命名空间之前声明,原因同上。
1 | class C { |
命名空间还能于同名 Enum 合并。
1 | enum E { |
注意,Enum 成员与命名空间导出成员不允许同名。
1 | enum E { |
十七、TypeScript 装饰器
17.1 简介
装饰器(Decorator)是一种语法结构,用来在定义时修改类(class)的行为。
在语法上,装饰器有如下几个特征。
(1)第一个字符(或者说前缀)是@,后面是一个表达式。
(2)@后面的表达式,必须是一个函数(或者执行后可以得到一个函数)。
(3)这个函数接受所修饰对象的一些相关值作为参数。
(4)这个函数要么不返回值,要么返回一个新对象取代所修饰的目标对象。
下面就是一个最简单的装饰器。
1 | function simpleDecorator( |
类A在执行前会先执行装饰器simpleDecorator(),并且会向装饰器自动传入参数就可以了。
装饰器有多种形式,基本上只要在@符号后面添加表达式都是可以的。下面都是合法的装饰器。
1 |
|
注意,@后面的表达式,最终执行后得到的应该是一个函数。
相比使用子类改变父类,装饰器更加简洁优雅,缺点是不那么直观,功能也受到一些限制。所以,装饰器一般只用来为类添加某种特定行为。
1 | class Foo { |
上面示例中,一共有四个装饰器,一个用在类本身(@frozen),另外三个用在类的方法(@configurable、@enumerable、@throttle)。它们不仅增加了代码的可读性,清晰地表达了意图,而且提供一种方便的手段,增加或修改类的功能。
17.2 装饰器的版本
目前,TypeScript 5.0 同时支持两种装饰器语法。标准语法可以直接使用,传统语法需要打开–experimentalDecorators编译参数。
1 | $ tsc --target ES5 --experimentalDecorators |
17.3 装饰器的结构
装饰器函数的类型定义如下。
1 | type Decorator = ( |
上面代码中,Decorator是装饰器的类型定义。它是一个函数,使用时会接收到
value和context两个参数。
value:所装饰的对象。
context:上下文对象,TypeScript 提供一个原生接口ClassMethodDecoratorContext,描述这个对象。
1 | function decorator( |
上面是一个装饰器函数,其中第二个参数context的类型就可以写成ClassMethodDecoratorContext。
context对象的属性,根据所装饰对象的不同而不同,其中只有两个属性(kind和name)是必有的,其他都是可选的。
(1)kind:字符串,表示所装饰对象的类型,可能取以下的值。
‘class’
‘method’
‘getter’
‘setter’
‘field’
‘accessor’
这表示一共有六种类型的装饰器。
(2)name:字符串或者 Symbol 值,所装饰对象的名字,比如类名、属性名等。
(3)addInitializer():函数,用来添加类的初始化逻辑。以前,这些逻辑通常放在构造函数里面,对方法进行初始化,现在改成以函数形式传入addInitializer()方法。注意,addInitializer()没有返回值。
(4)private:布尔值,表示所装饰的对象是否为类的私有成员。
(5)static:布尔值,表示所装饰的对象是否为类的静态成员。
(6)access:一个对象,包含了某个值的 get 和 set 方法。
17.4 类装饰器
类装饰器的类型描述如下。
1 | type ClassDecorator = ( |
类装饰器接受两个参数:value(当前类本身)和context(上下文对象)。其中,context对象的kind属性固定为字符串class。
类装饰器一般用来对类进行操作,可以不返回任何值,请看下面的例子。
1 | function Greeter(value, context) { |
类装饰器可以返回一个函数,替代当前类的构造方法。
1 | function countInstances(value:any, context:any) { |
注意,上例为了确保新构造方法继承定义在MyClass的原型之上的成员,特别加入A行,确保两者的原型对象是一致的。否则,新的构造函数wrapper的原型对象,与MyClass不同,通不过instanceof运算符。
类装饰器也可以返回一个新的类,替代原来所装饰的类。
1 | function countInstances(value:any, context:any) { |
下面的例子是通过类装饰器,禁止使用new命令新建类的实例。
1 | function functionCallable( |
类装饰器的上下文对象context的addInitializer()方法,用来定义一个类的初始化函数,在类完全定义结束后执行。
1 | function customElement(name: string) { |
17.5 方法装饰器
方法装饰器用来装饰类的方法(method)。它的类型描述如下。
1 | type ClassMethodDecorator = ( |
方法装饰器是一个函数,接受两个参数:value和context。
参数value是方法本身,参数context是上下文对象,有以下属性。
kind:值固定为字符串method,表示当前为方法装饰器。
name:所装饰的方法名,类型为字符串或 Symbol 值。
static:布尔值,表示是否为静态方法。该属性为只读属性。
private:布尔值,表示是否为私有方法。该属性为只读属性。
access:对象,包含了方法的存取器,但是只有get()方法用来取值,没有
set()方法进行赋值。
addInitializer():为方法增加初始化函数。
方法装饰器会改写类的原始方法,实质等同于下面的操作。
1 | function trace(decoratedMethod) { |
如果方法装饰器返回一个新的函数,就会替代所装饰的原始函数。
1 | function replaceMethod() { |
上面示例中,装饰器@replaceMethod返回的函数,就成为了新的hello()方法。
1 | class Person { |
上面示例中,装饰器@log的返回值是一个函数replacementMethod,替代了原始方法greet()。在replacementMethod()内部,通过执行originalMethod.call()完成了对原始方法的调用。
利用方法装饰器,可以将类的方法变成延迟执行。
1 | function delay(milliseconds: number = 0) { |
上面示例中,方法装饰器@delay(1000)将方法log()的执行推迟了1秒(1000毫秒)。这里真正的方法装饰器,是delay()执行后返回的函数,delay()的作用是接收参数,用来设置推迟执行的时间。这种通过高阶函数返回装饰器的做法,称为“工厂模式”,即可以像工厂那样生产出一个模子的装饰器。
方法装饰器的参数context对象里面,有一个addInitializer()方法。它是一个钩子方法,用来在类的初始化阶段,添加回调函数。这个回调函数就是作为addInitializer()的参数传入的,它会在构造方法执行期间执行,早于属性(field)的初始化。
下面是addInitializer()方法的一个例子。我们知道,类的方法往往需要在构造方法里面,进行this的绑定。
1 | class Person { |
上面例子中,类Person的构造方法内部,将this与greet()方法进行了绑定。如果没有这一行,将greet()赋值给变量g进行调用,就会报错了。
this的绑定必须放在构造方法里面,因为这必须在类的初始化阶段完成。现在,它可以移到方法装饰器的addInitializer()里面。
1 | function bound( |
下面再看一个例子,通过addInitializer()将选定的方法名,放入一个集合。
1 | function collect( |
17.6 属性装饰器
属性装饰器用来装饰定义在类顶部的属性(field)。它的类型描述如下。
1 | type ClassFieldDecorator = ( |
注意,装饰器的第一个参数value的类型是undefined,这意味着这个参数实际上没用的,装饰器不能从value获取所装饰属性的值。另外,第二个参数context对象的kind属性的值为字符串field,而不是“property”或“attribute”,这一点是需要注意的。
属性装饰器要么不返回值,要么返回一个函数,该函数会自动执行,用来对所装饰属性进行初始化。该函数的参数是所装饰属性的初始值,该函数的返回值是该属性的最终值。
1 | function logged(value, context) { |
上面示例中,属性装饰器@logged装饰属性name。@logged的返回值是一个函数,该函数用来对属性name进行初始化,它的参数initialValue就是属性name的初始值green。新建实例对象color时,该函数会自动执行。
属性装饰器的返回值函数,可以用来更改属性的初始值。
1 | function twice() { |
属性装饰器的上下文对象context的access属性,提供所装饰属性的存取器,请看
下面的例子。
1 | let acc; |
17.7 getter 装饰器,setter 装饰器
getter 装饰器和 setter 装饰器,是分别针对类的取值器(getter)和存值器(setter)的装饰器。它们的类型描述如下。
1 | type ClassGetterDecorator = ( |
注意,getter 装饰器的上下文对象context的access属性,只包含get()方法;setter 装饰器的access属性,只包含set()方法。
这两个装饰器要么不返回值,要么返回一个函数,取代原来的取值器或存值器。
下面的例子是将取值器的结果,保存为一个属性,加快后面的读取。
1 | class C { |
上面示例中,第一次读取inst.value,会进行计算,然后装饰器@lazy将结果存入只读属性value,后面再读取这个属性,就不会进行计算了。
17.8 accessor 装饰器
装饰器语法引入了一个新的属性修饰符accessor。
1 | class C { |
accessor修饰符等同于为属性x自动生成取值器和存值器,它们作用于私有属性x。也就是说,上面的代码等同于下面的代码。
1 | class C { |
accessor也可以与静态属性和私有属性一起使用。
1 | class C { |
accessor 装饰器的类型如下。
1 | type ClassAutoAccessorDecorator = ( |
accessor 装饰器的value参数,是一个包含get()方法和set()方法的对象。该装饰器可以不返回值,或者返回一个新的对象,用来取代原来的get()方法和set()方法。此外,装饰器返回的对象还可以包括一个init()方法,用来改变私有属性的初始值。
1 | class C { |
17.9 装饰器的执行顺序
装饰器的执行分为两个阶段。
(1)评估(evaluation):计算@符号后面的表达式的值,得到的应该是函数。
(2)应用(application):将评估装饰器后得到的函数,应用于所装饰对象。
也就是说,装饰器的执行顺序是,先评估所有装饰器表达式的值,再将其应用于当前类。
应用装饰器时,顺序依次为方法装饰器和属性装饰器,然后是类装饰器。
1 | function d(str:string) { |
类载入的时候,代码按照以下顺序执行。
(1)装饰器评估:这一步计算装饰器的值,首先是类装饰器,然后是类内部的装饰器,按照它们出现的顺序。
注意,如果属性名或方法名是计算值(本例是“计算方法名”),则它们在对应的装饰器评估之后,也会进行自身的评估。
(2)装饰器应用:实际执行装饰器函数,将它们与对应的方法和属性进行结合。
原型方法的装饰器首先应用,然后是静态属性和静态方法装饰器,接下来是实例属性装饰器,最后是类装饰器。
注意,“实例属性值”在类初始化的阶段并不执行,直到类实例化时才会执行。
如果一个方法或属性有多个装饰器,则内层的装饰器先执行,外层的装饰器后执行。
1 | class Person { |
十八、装饰器(旧语法)
TypeScript 早在2014年就支持装饰器,不过使用的是旧语法。
18.1 experimentalDecorators 编译选项
使用装饰器的旧语法,需要打开–experimentalDecorators编译选项。
1 | $ tsc --target ES5 --experimentalDecorators |
此外,还有另外一个编译选项–emitDecoratorMetadata,用来产生一些装饰器的元数据,供其他工具或某些模块(比如 reflect-metadata )使用。
这两个编译选项可以在命令行设置,也可以在tsconfig.json文件里面进行设置。
1 | { |
18.2 装饰器的种类
按照所装饰的不同对象,装饰器可以分成五类。
类装饰器(Class Decorators):用于类。
属性装饰器(Property Decorators):用于属性。
方法装饰器(Method Decorators):用于方法。
存取器装饰器(Accessor Decorators):用于类的 set 或 get 方法。
参数装饰器(Parameter Decorators):用于方法的参数。
1 | // (A) () |
注意,构造方法没有方法装饰器,只有参数装饰器。类装饰器其实就是在装饰构造方法。
另外,装饰器只能用于类,要么应用于类的整体,要么应用于类的内部成员,不能用于独立的函数。
18.3 类装饰器
类装饰器应用于类(class),但实际上是应用于类的构造方法。
类装饰器有唯一参数,就是构造方法,可以在装饰器内部,对构造方法进行各种改造。如果类装饰器有返回值,就会替换掉原来的构造方法。
类装饰器的类型定义如下。
1 | type ClassDecorator = <TFunction extends Function> |
上面定义中,类型参数TFunction必须是函数,实际上就是构造方法。类装饰器的返回值,要么是返回处理后的原始构造方法,要么返回一个新的构造方法。
1 | function f(target:any) { |
类A不需要新建实例,装饰器也会执行。装饰器会在代码加载阶段执行,而不是在运行时执行,而且只会执行一次。
由于 TypeScript 存在编译阶段,所以装饰器对类的行为的改变,实际上发生在编译阶段。这意味着,TypeScript 装饰器能在编译阶段运行代码,也就是说,它本质就是编译时执行的函数。
1 |
|
如果除了构造方法,类装饰器还需要其他参数,可以采取“工厂模式”,即把装饰器写在一个函数里面,该函数可以接受其他参数,执行后返回装饰器。但是,这样就需要调用装饰器的时候,先执行一次工厂函数。
1 | function factory(info:string) { |
总之,@后面要么是一个函数名,要么是函数表达式,甚至可以写出下面这样的代码。
1 | @((constructor: Function) => { |
类装饰器可以没有返回值,如果有返回值,就会替代所装饰的类的构造函数。由于JavaScript 的类等同于构造函数的语法糖,所以装饰器通常返回一个新的类,对原有的类进行修改或扩展。
18.4 方法装饰器
方法装饰器用来装饰类的方法,它的类型定义如下。
1 | type MethodDecorator = <T>( |
方法装饰器一共可以接受三个参数。
target:(对于类的静态方法)类的构造函数,或者(对于类的实例方法)类的原型。
propertyKey:所装饰方法的方法名,类型为string|symbol。
descriptor:所装饰方法的描述对象。
方法装饰器的返回值(如果有的话),就是修改后的该方法的描述对象,可以覆盖原始方法的描述对象。
1 | function enumerable(value: boolean) { |
方法装饰器@enumerable()装饰 Greeter 类的greet()方法,作用是修改该方法的描述对象的可遍历性属性enumerable。@enumerable(false)表示将该方法修改成不可遍历。
18.5 属性装饰器
属性装饰器用来装饰属性,类型定义如下。
1 | type PropertyDecorator = |
属性装饰器函数接受两个参数。
target:(对于实例属性)类的原型对象(prototype),或者(对于静态属性)类的构造函数。
propertyKey:所装饰属性的属性名,注意类型有可能是字符串,也有可能是Symbol 值。
属性装饰器不需要返回值,如果有的话,也会被忽略。
1 | function ValidRange(min:number, max:number) { |
上面示例中,装饰器ValidRange对属性year设立了一个上下限检查器,只要该属性赋值时,超过了上下限,就会报错。
注意,属性装饰器的第一个参数,对于实例属性是类的原型对象,而不是实例对象(即不是this对象)。这是因为装饰器执行时,类还没有新建实例,所以实例对象不存在。
由于拿不到this,所以属性装饰器无法获得实例属性的值。这也是它没有在参数里面提供属性描述对象的原因。
1 | function logProperty(target: Object, member: string) { |
上面示例中,属性装饰器@logProperty内部想要获取实例属性name的属性描述对象,结果拿到的是undefined。因为上例的target是类的原型对象,不是实例对象,所以拿不到name属性,也就是说target.name是不存在的,所以拿到的是undefined。只有通过this.name才能拿到name属性,但是这时this还不存在。
属性装饰器不仅无法获得实例属性的值,也不能初始化或修改实例属性,而且它的返回值也会被忽略。因此,它的作用很有限。
不过,如果属性装饰器设置了当前属性的存取器(getter/setter),然后在构造函数里面就可以对实例属性进行读写。
18.6 存取器装饰器
存取器装饰器用来装饰类的存取器(accessor)。所谓“存取器”指的是某个属性的取值器(getter)和存值器(setter)。
存取器装饰器的类型定义,与方法装饰器一致。
1 | type AccessorDecorator = <T>( |
存取器装饰器有三个参数。
target:(对于静态属性的存取器)类的构造函数,或者(对于实例属性的存取器)类的原型。
propertyKey:存取器的属性名。
descriptor:存取器的属性描述对象。
存取器装饰器的返回值(如果有的话),会作为该属性新的描述对象。
1 | function configurable(value: boolean) { |
上面示例中,装饰器@configurable(false)关闭了所装饰属性(x和y)的属性描述对象的configurable键(即关闭了属性的可配置性)。
TypeScript 不允许对同一个属性的存取器(getter 和 setter)使用同一个装饰器,也就是说只能装饰两个存取器里面的一个,且必须是排在前面的那一个,否则报错。
1 | // 报错 |
但是,下面的写法不会报错。
1 | class Person { |
上面示例中,@Decorator只装饰它后面第一个出现的存值器(set name()),并不装饰取值器(get name()),所以不报错。
装饰器之所以不能同时用于同一个属性的存值器和取值器,原因是装饰器可以从属性描述对象上面,同时拿到取值器和存值器,因此只调用一次就够了。
18.7 参数装饰器
参数装饰器用来装饰构造方法或者其他方法的参数。它的类型定义如下。
1 | type ParameterDecorator = ( |
参数装饰器接受三个参数。
target:(对于静态方法)类的构造函数,或者(对于类的实例方法)类的原型对象。
propertyKey:所装饰的方法的名字,类型为string|symbol。
parameterIndex:当前参数在方法的参数序列的位置(从0开始)。
该装饰器不需要返回值,如果有的话会被忽略。
1 | function log( |
上面示例中,参数装饰器会输出参数的位置序号。注意,后面的参数会先输出。
跟其他装饰器不同,参数装饰器主要用于输出信息,没有办法修改类的行为。
18.8 装饰器的执行顺序
装饰器只会执行一次,就是在代码解析时执行,哪怕根本没有调用类新建实例,也会执行,而且从此就不再执行了。
执行装饰器时,按照如下顺序执行。
- 实例相关的装饰器。
- 静态相关的装饰器。
- 构造方法的参数装饰器。
- 类装饰器。
1 | function f(key:string):any { |
同一级装饰器的执行顺序,是按照它们的代码顺序。但是,参数装饰器的执行总是早于方法装饰器。
1 | function f(key:string):any { |
如果同一个方法或属性有多个装饰器,那么装饰器将顺序加载、逆序执行。
1 | function f(key:string):any { |
如果同一个方法有多个参数,那么参数也是顺序加载、逆序执行。
1 | function f(key:string):any { |
18.9 为什么装饰器不能用于函数?
装饰器只能用于类和类的方法,不能用于函数,主要原因是存在函数提升。
JavaScript 的函数不管在代码的什么位置,都会提升到代码顶部。
如果允许装饰器可以用于普通函数,那么就有可能导致意想不到的情况。
1 | let counter = 0; |
总之,由于存在函数提升,使得装饰器不能用于函数。类是不会提升的,所以就没有这方面的问题。
另一方面,如果一定要装饰函数,可以采用高阶函数的形式直接执行,没必要写成装饰器。
1 | function doSomething(name) { |
18.10 多个装饰器的合成
多个装饰器可以应用于同一个目标对象,可以写在一行。
1 | x |
多个装饰器也可以写成多行。
1 |
|
多个装饰器的效果,类似于函数的合成,按照从里到外的顺序执行。对于上例来说,就是执行f(g(x))。
前面也说过,如果f和g是表达式,那么需要先从外到里求值。
十九、declare 关键字
19.1 简介
declare 关键字用来告诉编译器,某个类型是存在的,可以在当前文件中使用。
它的主要作用,就是让当前文件可以使用其他文件声明的类型。
declare 关键字可以描述以下类型。
变量(const、let、var 命令声明)
type 或者 interface 命令声明的类型
class
enum
函数(function)
模块(module)
命名空间(namespace)
declare 关键字的重要特点是,它只是通知编译器某个类型是存在的,不用给出具体实现。
declare 只能用来描述已经存在的变量和数据结构,不能用来声明新的变量和数据结构。另外,所有 declare 语句都不会出现在编译后的文件里面。
19.2 declare variable
declare 关键字可以给出外部变量的类型描述。
1 | //当前脚本使用了其他脚本定义的全局变量x。 |
上面示例中,变量x是其他脚本定义的,当前脚本不知道它的类型,编译器就会报错。
这时使用 declare 命令给出它的类型,就不会报错了。
1 | declare let x:number; |
如果 declare 关键字没有给出变量的具体类型,那么变量类型就是any。
1 | declare let x; |
下面的例子是脚本使用浏览器全局对象document。
1 | declare var document; |
上面示例中,declare 告诉编译器,变量document的类型是外部定义的(具体定义在 TypeScript 内置文件lib.d.ts)。
如果 TypeScript 没有找到document的外部定义,这里就会假定它的类型是any。
注意,declare 关键字只用来给出类型描述,是纯的类型代码,不允许设置变量的初始值,即不能涉及值。
1 | // 报错 |
19.3 declare function
declare 关键字可以给出外部函数的类型描述。
1 | declare function sayHello( |
注意,这种单独的函数类型声明语句,只能用于declare命令后面。一方面,TypeScript 不支持单独的函数类型声明语句;另一方面,declare 关键字后面也不能带有函数的具体实现。
19.4 declare class
declare 给出 class 的描述描述写法如下。
1 | declare class Animal { |
1 | declare class C { |
declare 后面不能给出 Class 的具体实现或初始值。
19.5 declare module,declare namespace
如果想把变量、函数、类组织在一起,可以将 declare 与 module 或 namespace 一起使用。
1 | declare namespace AnimalLib { |
上面示例中,declare 关键字给出了 module 或 namespace 的类型描述。
declare module 和 declare namespace 里面,加不加 export 关键字都可以。
1 | declare namespace Foo { |
下面的例子是当前脚本使用了myLib这个外部库,它有方法makeGreeting()和属性numberOfGreetings。
1 | let result = myLib.makeGreeting('你好'); |
myLib的类型描述就可以这样写。
1 | declare namespace myLib { |
declare 关键字的另一个用途,是为外部模块添加属性和方法时,给出新增部分的类型描述。
1 | import { Foo as Bar } from 'moduleA'; |
下面是另一个例子。一个项目有多个模块,可以在一个模型中,对另一个模块的接口进行类型扩展。
1 | // a.ts |
上面示例中,脚本a.ts定义了一个接口A,脚本b.ts为这个接口添加了属性y。declare module ‘./a’ {}表示对a.ts里面的模块,进行类型声明,而同名interface 会自动合并,所以等同于扩展类型。
使用这种语法进行模块的类型扩展时,有两点需要注意:
(1)declare module NAME语法里面的模块名NAME,跟 import 和 export 的模块名规则是一样的,且必须跟当前文件加载该模块的语句写法(上例import { A } from ‘./a’)保持一致。
(2)不能创建新的顶层类型。也就是说,只能对a.ts模块中已经存在的类型进行扩展,不允许增加新的顶层类型,比如新定义一个接口B。
(3)不能对默认的default接口进行扩展,只能对 export 命令输出的命名接口进行扩充。这是因为在进行类型扩展时,需要依赖输出的接口名。
某些第三方模块,原始作者没有提供接口类型,这时可以在自己的脚本顶部加上下面一行命令。
1 | declare module "模块名"; |
加上上面的命令以后,外部模块即使没有类型,也可以通过编译。但是,从该模块输入的所有接口都将为any类型。
declare module 描述的模块名可以使用通配符。
1 | declare module 'my-plugin-*' { |
19.6 declare global
如果要为 JavaScript 引擎的原生对象添加属性和方法,可以使用declare global {}语法。
1 | export {}; |
这个示例第一行的空导出语句export {},作用是强制编译器将这个脚本当作模块处理。这是因为declare global必须用在模块里面。
1 | export {}; |
declare global 只能扩充现有对象的类型描述,不能增加新的顶层类型。
19.7 declare enum
declare 关键字给出 enum 类型描述的例子如下,下面的写法都是允许的。
1 | declare enum E1 { |
19.8 declare module 用于类型声明文件
我们可以为每个模块脚本,定义一个.d.ts文件,把该脚本用到的类型定义都放在这个文件里面。但是,更方便的做法是为整个项目,定义一个大的.d.ts文件,在这个文件里面使用declare module定义每个模块脚本的类型。
1 | declare module "url" { |
使用时,自己的脚本使用三斜杠命令,加载这个类型声明文件。
1 | /// <reference path="node.d.ts"/> |
如果没有上面这一行命令,自己的脚本使用外部模块时,就需要在脚本里面使用declare 命令单独给出外部模块的类型。
二十、d.ts 类型声明文件
20.1 简介
单独使用的模块,一般会同时提供一个单独的类型声明文件(declaration file),把本模块的外部接口的所有类型都写在这个文件里面,便于模块使用者了解接口,也便于编译器检查使用者的用法是否正确。
类型声明文件里面只有类型代码,没有具体的代码实现。它的文件名一般为[模块名].d.ts的形式,其中的d表示 declaration(声明)。
1 | const maxInterval = 12; |
类型声明文件也可以使用export =命令,输出对外接口。下面是 moment 模块的类型声明文件的例子。
1 | declare module 'moment' { |
模块moment内部有一个函数moment(),而export =表示module.exports输出的就是这个函数。
除了使用export =,模块输出在类型声明文件中,也可以使用export default表示。
1 | // 模块输出 |
下面是一个如何使用类型声明文件的简单例子。有一个类型声明文件types.d.ts。
1 | // types.d.ts |
然后,就可以在 TypeScript 脚本里面导入该文件声明的类型。
1 | // index.ts |
类型声明文件也可以包括在项目的 tsconfig.json 文件里面,这样的话,编译器打包项目时,会自动将类型声明文件加入编译,而不必在每个脚本里面加载类型声明文件。比如,moment 模块的类型声明文件是moment.d.ts,使用 moment 模块的项目可以将其加入项目的 tsconfig.json 文件。
1 | { |
20.2 类型声明文件的来源
类型声明文件主要有以下三种来源。
TypeScript 编译器自动生成。
TypeScript 内置类型文件。
外部模块的类型声明文件,需要自己安装。
20.2.1 自动生成
只要使用编译选项declaration,编译器就会在编译时自动生成单独的类型声明文
件。
下面是在tsconfig.json文件里面,打开这个选项。
1 | { |
也可以在命令行打开这个选项。
1 | $ tsc --declaration |
20.2.2 内置声明文件
安装 TypeScript 语言时,会同时安装一些内置的类型声明文件,主要是内置的全局对象(JavaScript 语言接口和运行环境 API)的类型声明。
这些内置声明文件位于 TypeScript 语言安装目录的lib文件夹内,数量大概有几十个。
这些内置声明文件的文件名统一为“lib.[description].d.ts”的形式,其中description部分描述了文件内容。
TypeScript 编译器会自动根据编译目标target的值,加载对应的内置声明文件,所以不需要特别的配置。但是,可以使用编译选项lib,指定加载哪些内置声明文件。
1 | { |
编译选项noLib会禁止加载任何内置声明文件。
20.2.3 外部类型声明文件
如果项目中使用了外部的某个第三方代码库,那么就需要这个库的类型声明文件。
(1)这个库自带了类型声明文件。
一般来说,如果这个库的源码包含了[vendor].d.ts文件,那么就自带了类型声明文件。其中的vendor表示这个库的名字,比如moment这个库就自带moment.d.ts。使用这个库可能需要单独加载它的类型声明文件。
(2)这个库没有自带,但是可以找到社区制作的类型声明文件。
第三方库如果没有提供类型声明文件,社区往往会提供。TypeScript 社区主要使用DefinitelyTyped 仓库,各种类型声明文件都会提交到那里,已经包含了几千个第三方库。
这些声明文件都会作为一个单独的库,发布到 npm 的@types名称空间之下。
如果类型声明文件不是index.d.ts,那么就需要在package.json的types或typings字段,指定类型声明文件的文件名。
TypeScript 会自动加载node_modules/@types目录下的模块,但可以使用编译选项typeRoots改变这种行为。
1 | { |
默认情况下,TypeScript 会自动加载typeRoots目录里的所有模块,编译选项types可以指定加载哪些模块。
1 | { |
上面设置中,types属性是一个数组,成员是所要加载的类型模块,要加载几个模块,这个数组就有几个成员,每个类型模块在typeRoots目录下都有一个自己的子目录。这样的话,TypeScript 就会自动去jquery子目录,加载 jQuery 的类型声明文件。
(3)找不到类型声明文件,需要自己写。
有时实在没有第三方库的类型声明文件,又很难完整给出该库的类型描述,这时你可以告诉 TypeScript 相关对象的类型是any。
1 | declare var $:any |
也可以采用下面的写法,将整个外部模块的类型设为any。
1 | declare module '模块名'; |
20.3 declare 关键字
类型声明文件只包含类型描述,不包含具体实现,所以非常适合使用 declare 语句来描述类型。
类型声明文件里面,变量的类型描述必须使用declare命令,否则会报错,因为变量声明语句是值相关代码。
1 | declare let foo:string; |
interface 类型有没有declare都可以,因为 interface 是完全的类型代码。
1 | interface Foo {} // 正确 |
类型声明文件里面,顶层可以使用export命令,也可以不用,除非使用者脚本会显式使用export命令输入类型。
1 | export interface Data { |
下面是类型声明文件的一些例子。先看 moment 模块的类型描述文件moment.d.ts。
1 | declare module 'moment' { |
20.4 模块发布
当前模块如果包含自己的类型声明文件,可以在 package.json 文件里面添加一个types字段或typings字段,指明类型声明文件的位置。
1 | { |
注意,如果类型声明文件名为index.d.ts,且在项目的根目录中,那就不需要在package.json里面注明了。
有时,类型声明文件会单独发布成一个 npm 模块,这时用户就必须同时加载该模块。
1 | { |
上面示例是一个模块的 package.json 文件,该模块需要 browserify 模块。由于后者的类型声明文件是一个单独的模块@types/browserify,所以还需要加载那个模块。
20.5 三斜杠命令
如果类型声明文件的内容非常多,可以拆分成多个文件,然后入口文件使用三斜杠命令,加载其他拆分后的文件。
三斜杠命令(///)是一个 TypeScript 编译器命令,用来指定编译器行为。它只能用在文件的头部,如果用在其他地方,会被当作普通的注释。另外,若一个文件中使用了三斜线命令,那么在三斜线命令之前只允许使用单行注释、多行注释和其他三斜线命令,否则三斜杠命令也会被当作普通的注释。
除了拆分类型声明文件,三斜杠命令也可以用于普通脚本加载类型声明文件。
三斜杠命令主要包含三个参数,代表三种不同的命令。
path
types
lib
/// <**reference path=””** />是最常见的三斜杠命令,告诉编译器在编译时需要包括的文件,常用来声明当前脚本依赖的类型文件。
1 | /// <reference path="./lib.ts" /> |
上面示例表示,当前脚本依赖于./lib.ts,里面是add()的定义。编译当前脚本时,还会同时编译./lib.ts。编译产物会有两个 JS 文件,一个当前脚本,另一个就是./lib.js。
下面的例子是当前脚本依赖于 Node.js 类型声明文件。
1 | /// <reference path="node.d.ts"/> |
编译器会在预处理阶段,找出所有三斜杠引用的文件,将其添加到编译列表中,然后一起编译。
path参数指定了所引入文件的路径。如果该路径是一个相对路径,则基于当前脚本的路径进行计算。
使用该命令时,有以下两个注意事项。
path参数必须指向一个存在的文件,若文件不存在会报错。
path参数不允许指向当前文件。
默认情况下,每个三斜杠命令引入的脚本,都会编译成单独的 JS 文件。如果希望编译后只产出一个合并文件,可以使用编译选项outFile。但是,outFile编译选项不支持合并 CommonJS 模块和 ES 模块,只有当编译参数module的值设为 None、System 或 AMD 时,才能编译成一个文件。
如果打开了编译参数noResolve,则忽略三斜杠指令。将其当作一般的注释,原样保留在编译产物中。
types 参数用来告诉编译器当前脚本依赖某个 DefinitelyTyped 类型库,通常安装在node_modules/@types目录。
types 参数的值是类型库的名称,也就是安装到node_modules/@types目录中的子目录的名字。
1 | /// <reference types="node" /> |
上面示例中,这个三斜杠命令表示编译时添加 Node.js 的类型库,实际添加的脚本是node_modules目录里面的@types/node/index.d.ts。
可以看到,这个命令的作用类似于import命令。
注意,这个命令只在你自己手写类型声明文件(.d.ts文件)时,才有必要用到,也就是说,只应该用在.d.ts文件中,普通的.ts脚本文件不需要写这个命令。如果是普通的.ts脚本,可以使用tsconfig.json文件的types属性指定依赖的类型库。
/// <**reference lib=”…”** />命令允许脚本文件显式包含内置 lib 库,等同于在tsconfig.json文件里面使用lib属性指定 lib 库。
二十一、TypeScript 类型运算符
21.1 keyof 运算符
21.1.1 简介
keyof 是一个单目运算符,接受一个对象类型作为参数,返回该对象的所有键名组成的联合类型。
1 | type MyObj = { |
由于 JavaScript 对象的键名只有三种类型,所以对于任意对象的键名的联合类型就是string|number|symbol。
1 | // string | number | symbol |
对于没有自定义键名的类型使用 keyof 运算符,返回never类型,表示不可能有这样类型的键名。
1 | type KeyT = keyof object; // never |
由于 keyof 返回的类型是string|number|symbol,如果有些场合只需要其中的一种类型,那么可以采用交叉类型的写法。
1 | type Capital<T extends string> = Capitalize<T>; |
类型Capital只接受字符串作为类型参数,传入keyof Obj会报错,原因是这时的类型参数是string|number|symbol,跟字符串不兼容。采用下面的交叉类型写法,就不会报错。
1 | type MyKeys<Obj extends object> = Capital<string & keyof Obj>; |
如果对象属性名采用索引形式,keyof 会返回属性名的索引类型。
1 | // 示例一 |
上面的示例二,keyof T返回的类型是string|number,原因是 JavaScript 属性名为字符串时,包含了属性名为数值的情况,因为数值属性名会自动转为字符串。
如果 keyof 运算符用于数组或元组类型,得到的结果可能出人意料。
1 | type Result = keyof ['a', 'b', 'c']; |
上面示例中,keyof 会返回数组的所有键名,包括数字键名和继承的键名。
对于联合类型,keyof 返回成员共有的键名。
1 | type A = { a: string; z: boolean }; |
对于交叉类型,keyof 返回所有键名。
1 | type A = { a: string; x: boolean }; |
keyof 取出的是键名组成的联合类型,如果想取出键值组成的联合类型,可以像下面这样写。
1 | type MyObj = { |
21.1.2 keyof 运算符的用途
keyof 运算符往往用于精确表达对象的属性类型。
1 | //举例来说,取出对象的某个指定属性的值,JavaScript 版本可以写成下面这样。 |
上面的类型声明有两个问题,一是无法表示参数key与参数obj之间的关系,二是返回值类型只能写成any。
有了 keyof 以后,就可以解决这两个问题,精确表达返回值类型。
1 | function prop<Obj, K extends keyof Obj>( |
keyof 的另一个用途是用于属性映射,即将一个类型的所有属性逐一映射成其他值。
1 | type NewProps<Obj> = { |
上面示例中,类型NewProps是类型Obj的映射类型,前者继承了后者的所有属性,但是把所有属性值类型都改成了boolean。
下面的例子是去掉 readonly 修饰符。
1 | type Mutable<Obj> = { |
上面示例中,[Prop in keyof Obj]是Obj类型的所有属性名,-readonly表示去除这些属性的只读特性。对应地,还有+readonly的写法,表示添加只读属性设置。
下面的例子是让可选属性变成必有的属性。
1 | type Concrete<Obj> = { |
上面示例中,[Prop in keyof Obj]后面的-?表示去除可选属性设置。对应地,还有+?的写法,表示添加可选属性设置。
21.2 in 运算符
JavaScript 语言中,in运算符用来确定对象是否包含某个属性名。
TypeScript 语言的类型运算中,in运算符有不同的用法,用来取出(遍历)联合类型的每一个成员类型。
1 | type U = 'a'|'b'|'c'; |
21.3 方括号运算符
方括号运算符([])用于取出对象的键值类型,比如T[K]会返回对象T的属性K的类型。
1 | type Person = { |
方括号的参数如果是联合类型,那么返回的也是联合类型。
1 | type Person = { |
如果访问不存在的属性,会报错。
1 | type T = Person['notExisted']; // 报错 |
方括号运算符的参数也可以是属性名的索引类型。
1 | type Obj = { |
上面示例中,Obj的属性名是字符串的索引类型,所以可以写成Obj[string],代表所有字符串属性名,返回的就是它们的类型number。
这个语法对于数组也适用,可以使用number作为方括号的参数。
1 | // MyArray 的类型是 { [key:number]:string } |
注意,方括号里面不能有值的运算。
1 | // 示例一 |
21.4 extends…?: 条件运算符
TypeScript 提供类似 JavaScript 的?:运算符这样的三元运算符,但多出了一个extends关键字。
条件运算符extends…?:可以根据当前类型是否符合某种条件,返回不同的类型。
1 | T extends U ? X : Y |
上面式子中的extends用来判断,类型T是否可以赋值给类型U,即T是否为U的子类型,这里的T和U可以是任意类型。
如果T能够赋值给类型U,表达式的结果为类型X,否则结果为类型Y。
一般来说,调换extends两侧类型,会返回相反的结果。
如果需要判断的类型是一个联合类型,那么条件运算符会展开这个联合类型。
1 | (A|B) extends U ? X : Y |
上面示例中,A|B是一个联合类型,进行条件运算时,相当于A和B分别进行运算符,返回结果组成一个联合类型。
如果不希望联合类型被条件运算符展开,可以把extends两侧的操作数都放在方括号里面。
1 | // 示例一 |
条件运算符还可以嵌套使用。
1 | type LiteralTypeName<T> = |
上面示例是一个多重判断,返回一个字符串的值类型,对应当前类型。下面是它的用法。
1 | // "bigint" |
21.5 infer 关键字
infer关键字用来定义泛型里面推断出来的类型参数,而不是外部传入的类型参数。
它通常跟条件运算符一起使用,用在extends关键字后面的父类型之中。
1 | type Flatten<Type> = |
上面示例中,Type是外部传入的类型参数,如果它是数组Array的子类型,那么就将类型变量Item推断为T,即Item代表数组的成员类型,写成infer Item,表示Item这个类型参数是从当前信息中推断出来的。
一旦定义了Item,后面的代码就可以使用这个类型参数了。
下面是上例的泛型Flatten<**Type**>的用法。
1 | // string |
上面示例中,第一个例子Flatten<**string[]**>传入的类型参数是string[],可以推断出Item的类型是string,所以返回的是string。第二个例子Flatten<**number**>传入的类型参数是number,它不是数组的子类型,所以直接返回自身。
如果不用infer定义类型参数,那么就要传入两个类型参数。
1 | type Flatten<Type, Item> = |
下面的例子使用infer,推断函数的参数类型和返回值类型。
1 | type ReturnPromise<T> = |
上面示例中,如果T是函数,就返回这个函数的 Promise 版本,否则原样返回。infer A表示该函数的参数类型为A,infer R表示该函数的返回值类型为R。
下面是infer提取对象指定属性的例子。
1 | type MyType<T> = |
下面是infer通过正则匹配提取类型参数的例子。
1 | type Str = 'foo-bar'; |
21.6 is 运算符
函数返回布尔值的时候,可以使用is运算符,限定返回值与参数之间的关系。
is运算符用来描述返回值属于true还是false。
1 | function isFish( |
is运算符总是用于描述函数的返回值类型,写法采用parameterName is Type的形式,即左侧为当前函数的参数名,右侧为某一种类型。它返回一个布尔值,表示左侧参数是否属于右侧的类型。
1 | type A = { a: string }; |
is运算符可以用于类型保护。
1 | function isCat(a:any): a is Cat { |
上面示例中,函数isCat()的返回类型是a is Cat,它是一个布尔值。后面的if语句就用这个返回值进行判断,从而起到类型保护的作用,确保x是 Cat 类型,从而x.meow()不会报错(假定Cat类型拥有meow()方法)。
is运算符还有一种特殊用法,就是用在类(class)的内部,描述类的方法的返回值。
1 | class Teacher { |
注意,this is T这种写法,只能用来描述方法的返回值类型,而不能用来描述属性的类型。
21.7 模板字符串
TypeScript 允许使用模板字符串,构建类型。
模板字符串的最大特点,就是内部可以引用其他类型。
1 | type World = "world"; |
注意,模板字符串可以引用的类型一共6种,分别是 string、number、bigint、boolean、null、undefined。引用这6种以外的类型会报错。
模板字符串里面引用的类型,如果是一个联合类型,那么它返回的也是一个联合类型,即模板字符串可以展开联合类型。
1 | type T = 'A'|'B'; |
如果模板字符串引用两个联合类型,它会交叉展开这两个类型。
1 | type T = 'A'|'B'; |
二十二、TypeScript 的类型映射
22.1 简介
映射(mapping)指的是,将一种类型按照映射规则,转换成另一种类型,通常用于对象类型。
举例来说,现有一个类型A和另一个类型B。
1 | type A = { |
使用类型映射,就可以从类型A得到类型B。
1 | type A = { |
在语法上,[prop in keyof A]是一个属性名表达式,表示这里的属性名需要计算得到。具体的计算规则如下:
prop:属性名变量,名字可以随便起。
in:运算符,用来取出右侧的联合类型的每一个成员。
Keyof A:返回类型A的每一个属性名,组成一个联合类型。
下面是复制原始类型的例子。
1 | type A = { |
为了增加代码复用性,可以把常用的映射写成泛型。
1 | type ToBoolean<Type> = { |
不使用联合类型,直接使用某种具体类型进行属性名映射,也是可以的。
1 | type MyObj = { |
1 | type MyObj = { |
上面示例中,[p in string]就是属性名索引形式[p: string]的映射写法。
通过映射,可以某个对象的所有属性改成可选属性。
1 | type A = { |
事实上,TypeScript 的内置工具类型Partial<**T**>,就是这样实现的。
TypeScript内置的工具类型Readonly<**T**>可以将所有属性改为只读属性,实现也是通过映射。
1 | // 将 T 的所有属性改为只读属性 |
22.2 映射修饰符
映射会原样复制原始对象的可选属性和只读属性。
1 | type A = { |
如果要删改可选和只读这两个特性,并不是很方便。为了解决这个问题,TypeScript引入了两个映射修饰符,用来在映射时添加或移除某个属性的?修饰符和readonly修饰符。
+修饰符:写成+?或+readonly,为映射属性添加?修饰符或readonly修饰符。
–修饰符:写成-?或-readonly,为映射属性移除?修饰符或readonly修饰符。
下面是添加或移除可选属性的例子。
1 | // 添加可选属性 |
注意,+?或-?要写在属性名的后面。
下面是添加或移除只读属性的例子。
1 | // 添加 readonly |
注意,+readonly和-readonly要写在属性名的前面。
如果同时增删?和readonly这两个修饰符,写成下面这样。
1 | // 增加 |
TypeScript 原生的工具类型Required<**T**>专门移除可选属性,就是使用-?修饰符实现的。
注意,–?修饰符移除了可选属性以后,该属性就不能等于undefined了,实际变成必选属性了。但是,这个修饰符不会移除null类型。
另外,+?修饰符可以简写成?,+readonly修饰符可以简写成readonly。
1 | type A<T> = { |
22.3 键名重映射
22.3.1 语法
TypeScript 4.1 引入了键名重映射(key remapping),允许改变键名。
1 | type A = { |
键名重映射的语法是在键名映射的后面加上as + 新类型子句。这里的“新类型”通常是一个模板字符串,里面可以对原始键名进行各种操作。
1 | interface Person { |
它的修改键名的代码是一个模板字符串get${Capitalize<**string & P**>},下面是各个部分的解释。
get:为键名添加的前缀。
Capitalize<**T**>:一个原生的工具泛型,用来将T的首字母变成大写。
string & P:一个交叉类型,其中的P是 keyof 运算符返回的键名联合类型string|number|symbol,但是Capitalize<**T**>只能接受字符串作为类型参数,因此string & P只返回P的字符串属性名。
22.3.2 属性过滤
键名重映射还可以过滤掉某些属性。下面的例子是只保留字符串属性。
1 | type User = { |
它的键名重映射as T[K] extends string ? K : never],使用了条件运算符。如果属性值T[K]的类型是字符串,那么属性名不变,否则属性名类型改为never,即这个属性名不存在。这样就等于过滤了不符合条件的属性,只保留属性值为字符串的属性。
22.3.3 联合类型的映射
由于键名重映射可以修改键名类型,所以原始键名的类型不必是string|number|symbol,任意的联合类型都可以用来进行键名重映射。
1 | type S = { |
原始键名的映射是E in Events,这里的Events是两个对象组成的联合类型S|C。所以,E是一个对象,然后再通过键名重映射,得到字符串键名E[‘kind’]。
二十三、TypeScript 类型工具
TypeScript 内置了17个类型工具,可以直接使用。
23.1 Awaited< Type >
Awaited<**Type**>用来取出 Promise 的返回值类型,适合用在描述then()方法和await 命令的参数类型。
1 | // string |
它也可以返回多重 Promise 的返回值类型。
如果它的类型参数不是 Promise 类型,那么就会原样返回。
1 | // number | boolean |
Awaited<**Type**>的实现如下。
1 | type Awaited<T> = |
23.2 ConstructorParameters< Type >
ConstructorParameters<**Type**>提取构造方法Type的参数类型,组成一个元组类型返回。
1 | type T1 = ConstructorParameters< |
它可以返回一些内置构造方法的参数类型。
1 | type T1 = ConstructorParameters< |
如果参数类型不是构造方法,就会报错。
any类型和never类型是两个特殊值,分别返回unknown[]和never。
ConstructorParameters<**Type**>的实现如下。
1 | type ConstructorParameters< |
23.3 Exclude< UnionType, ExcludedMembers >
Exclude<**UnionType, ExcludedMembers**>用来从联合类型UnionType里面,删除某些类型ExcludedMembers,组成一个新的类型返回。
1 | type T1 = Exclude<'a'|'b'|'c', 'a'>; // 'b'|'c' |
Exclude<**UnionType, ExcludedMembers**>的实现如下。
1 | type Exclude<T, U> = T extends U ? never : T; |
上面代码中,等号右边的部分,表示先判断T是否兼容U,如果是的就返回never类型,否则返回当前类型T。由于never类型是任何其他类型的子类型,它跟其他类型组成联合类型时,可以直接将never类型从联合类型中“消掉”,因此Exclude<**T, U**>就相当于删除兼容的类型,剩下不兼容的类型。
23.4 Extract< Type, Union >
Extract<**UnionType, Union**>用来从联合类型UnionType之中,提取指定类型Union,组成一个新类型返回。它与Exclude<**T, U**>正好相反。
1 | type T1 = Extract<'a'|'b'|'c', 'a'>; // 'a' |
如果参数类型Union不包含在联合类型UnionType之中,则返回never类型。
Extract<**UnionType, Union**>的实现如下。
1 | type Extract<T, U> = T extends U ? T : never; |
23.5 InstanceType< Type >
InstanceType<**Type**>提取构造函数的返回值的类型(即实例类型),参数Type是一个构造函数,等同于构造函数的ReturnType<**Type**>。
1 | type T = InstanceType< |
下面是一些例子。
1 | type A = InstanceType<ErrorConstructor>; // Error |
上面示例中,InstanceType<**T**>的参数都是 TypeScript 内置的原生对象的构造函数类型,InstanceType<**T**>的返回值就是这些构造函数的实例类型。
由于 Class 作为类型,代表实例类型。要获取它的构造方法,必须把它当成值,然后用typeof运算符获取它的构造方法类型。
1 | class C { |
如果类型参数不是构造方法,就会报错。
如果类型参数是any或never两个特殊值,分别返回any和never。
InstanceType<**Type**>的实现如下。
1 | type InstanceType< |
23.6 NonNullable< Type >
NonNullable<**Type**>用来从联合类型Type删除null类型和undefined类型,组成一个新类型返回,也就是返回Type的非空类型版本。
1 | // string|number |
NonNullable<**Type**>的实现如下。
1 | type NonNullable<T> = T & {} |
上面代码中,T & {}等同于求T & Object的交叉类型。由于 TypeScript 的非空值都属于Object的子类型,所以会返回自身;而null和undefined不属于Object,会返回never类型。
23.7 Omit< Type, Keys >
Omit<**Type, Keys**>用来从对象类型Type中,删除指定的属性Keys,组成一个新的对象类型返回。
1 | interface A { |
指定删除的键名Keys可以是对象类型Type中不存在的属性,但必须兼容string|number|symbol。
Omit<**Type, Keys**>的实现如下。
1 | type Omit<T, K extends keyof any> |
23.8 OmitThisParameter< Type >
OmitThisParameter<**Type**>从函数类型中移除 this 参数。
1 | function toHex(this: Number) { |
如果函数没有 this 参数,则返回原始函数类型。
OmitThisParameter<**Type**>的实现如下。
1 | type OmitThisParameter<T> = |
23.9 Parameters< Type >
Parameters<**Type**>从函数类型Type里面提取参数类型,组成一个元组返回。
1 | type T1 = Parameters<() => string>; // [] |
上面示例中,Parameters<**Type**>的返回值会包括函数的参数名,这一点需要注意。
如果参数类型Type不是带有参数的函数形式,会报错。
1 | // 报错 |
由于any和never是两个特殊值,会返回unknown[]和never。
Parameters<**Type**>主要用于从外部模块提供的函数类型中,获取参数类型。
1 | interface SecretName { |
上面示例中,模块只输出了函数getGift(),没有输出参数SecretName和返回值SecretSanta。这时就可以通过Parameters<**T**>和ReturnType<**T**>拿到这两个接口类型。
1 | type ParaT = Parameters<typeof getGift>[0]; // SecretName |
Parameters<**Type**>的实现如下。
1 | type Parameters<T extends (...args: any) => any> = |
23.10 Partial< Type >
Partial<**Type**>返回一个新类型,将参数类型Type的所有属性变为可选属性。
1 | interface A { |
Partial<**Type**>的实现如下。
1 | type Partial<T> = { |
23.11 Pick< Type, Keys >
Pick<**Type, Keys**>返回一个新的对象类型,第一个参数Type是一个对象类型,第二个参数Keys是Type里面被选定的键名。
1 | interface A { |
指定的键名Keys必须是对象键名Type里面已经存在的键名,否则会报错。
1 | interface A { |
Pick<**Type, Keys**>的实现如下。
1 | type Pick<T, K extends keyof T> = { |
23.12 Readonly< Type >
Readonly<**Type**>返回一个新类型,将参数类型Type的所有属性变为只读属性。
1 | interface A { |
Readonly<**Type**>的实现如下。
1 | type Readonly<T> = { |
23.13 Record< Keys, Type >
Record<**Keys, Type**>返回一个对象类型,参数Keys用作键名,参数Type用作键值类型。
1 | // { a: number } |
参数Keys可以是联合类型,这时会依次展开为多个键。
1 | // { a: number, b: number } |
如果参数Type是联合类型,就表明键值是联合类型。
参数Keys的类型必须兼容string|number|symbol,否则不能用作键名,会报错。
Record<**Keys, Type**>的实现如下。
1 | type Record<K extends string|number|symbol, T> |
23.14 Required< Type >
Required<**Type**>返回一个新类型,将参数类型Type的所有属性变为必选属性。它与Partial<**Type**>的作用正好相反。
1 | interface A { |
Required<**Type**>的实现如下。
1 | type Required<T> = { |
23.15 ReadonlyArray< Type >
ReadonlyArray<**Type**>用来生成一个只读数组类型,类型参数Type表示数组成员的类型。
1 | const values: ReadonlyArray<string> |
上面示例中,变量values的类型是一个只读数组,所以修改成员会报错,并且那些会修改源数组的方法push()、pop()、splice()等都不存在。
ReadonlyArray<**Type**>的实现如下。
1 | interface ReadonlyArray<T> { |
23.16 ReturnType< Type >
ReturnType<**Type**>提取函数类型Type的返回值类型,作为一个新类型返回。
1 | type T1 = ReturnType<() => string>; // string |
如果参数类型是泛型函数,返回值取决于泛型类型。如果泛型不带有限制条件,就会返回unknown。
1 | type T1 = ReturnType<<T>() => T>; // unknown |
如果类型不是函数,会报错。
any和never是两个特殊值,分别返回any和never。
ReturnType<**Type**>的实现如下。
1 | type ReturnType< |
23.17 ThisParameterType< Type >
ThisParameterType<**Type**>提取函数类型中this参数的类型。
1 | function toHex(this: Number) { |
如果函数没有this参数,则返回unknown。
ThisParameterType<**Type**>的实现如下。
1 | type ThisParameterType<T> = |
23.18 ThisType< Type >
ThisType<**Type**>不返回类型,只用来跟其他类型组成交叉类型,用来提示TypeScript 其他类型里面的this的类型。
1 | interface HelperThisValue { |
上面示例中,变量helperFunctions的类型是一个正常的对象类型与ThisType<**HelperThisValue**>组成的交叉类型。
这里的ThisType的作用是提示 TypeScript,变量helperFunctions的this应该满足HelperThisValue的条件。所以,this.logError()可以正确调用,而this.update()会报错,因为HelperThisValue里面没有这个方法。
注意,使用这个类型工具时,必须打开noImplicitThis设置。
ThisType<**Type**>的实现就是一个空接口。
1 | interface ThisType<T> { } |
23.19 字符串类型工具
TypeScript 内置了四个字符串类型工具,专门用来操作字符串类型。这四个工具类型都定义在 TypeScript 自带的.d.ts文件里面。
它们的实现都是在底层调用 JavaScript 引擎提供 JavaScript 字符操作方法。
Uppercase<**StringType**>将字符串类型的每个字符转为大写。
Lowercase<**StringType**>将字符串的每个字符转为小写。
Capitalize<**StringType**>将字符串的第一个字符转为大写。
Uncapitalize<**StringType**> 将字符串的第一个字符转为小写。
二十四、TypeScript 的注释指令
TypeScript 接受一些注释指令。
所谓“注释指令”,指的是采用 JS 双斜杠注释的形式,向编译器发出的命令。
24.1 // @ts-nocheck
// @ts-nocheck告诉编译器不对当前脚本进行类型检查,可以用于 TypeScript 脚本,也可以用于 JavaScript 脚本。
1 | // @ts-nocheck |
24.2 // @ts-check
如果一个 JavaScript 脚本顶部添加了// @ts-check,那么编译器将对该脚本进行类型检查,不论是否启用了checkJs编译选项。
1 | // @ts-check |
24.3 // @ts-ignore
// @ts-ignore或// @ts-expect-error,告诉编译器不对下一行代码进行类型检查,可以用于 TypeScript 脚本,也可以用于 JavaScript 脚本。
1 | let x:number; |
24.4 JSDoc
TypeScript 直接处理 JS 文件时,如果无法推断出类型,会使用 JS 脚本里面的 JSDoc注释。
使用 JSDoc 时,有两个基本要求。
(1)JSDoc 注释必须以/*开始,其中星号()的数量必须为两个。若使用其他形式的多行注释,则 JSDoc 会忽略该条注释。
(2)JSDoc 注释必须与它描述的代码处于相邻的位置,并且注释在上,代码在下。
1 | /** |
TypeScript 编译器支持大部分的 JSDoc 声明。
24.4.1 @typedef
@typedef命令创建自定义类型,等同于 TypeScript 里面的类型别名。
1 | /** |
24.4.2 @type
@type命令定义变量的类型。
1 | /** |
在@type命令中可以使用由@typedef命令创建的类型。
1 | /** |
在@type命令中允许使用 TypeScript 类型及其语法。
1 | /**@type {true | false} */ |
24.4.3 @param
@param命令用于定义函数参数的类型。
1 | /** |
如果是可选参数,需要将参数名放在方括号[]里面。
1 | /** |
方括号里面,还可以指定参数默认值。
1 | /** |
24.4.4 @return,@returns
@return和@returns命令的作用相同,指定函数返回值的类型。
1 | /** |
24.4.5 @extends 和类型修饰符
@extends命令用于定义继承的基类。
1 | /** |
@public、@protected、@private分别指定类的公开成员、保护成员和私有成员。
@readonly指定只读成员。
1 | class Base { |
二十五、tsconfig.json
25.1 简介
tsconfig.json是 TypeScript 项目的配置文件,放在项目的根目录。反过来说,如果一个目录里面有tsconfig.json,TypeScript 就认为这是项目的根目录。
如果项目源码是 JavaScript,但是想用 TypeScript 处理,那么配置文件的名字是jsconfig.json,它跟tsconfig的写法是一样的。
tsconfig.json文件主要供tsc编译器使用,它的命令行参数–project或-p可以指定tsconfig.json的位置(目录或文件皆可)。
1 | $ tsc -p ./dir |
如果不指定配置文件的位置,tsc就会在当前目录下搜索tsconfig.json文件,如果不存在,就到上一级目录搜索,直到找到为止。
tsconfig.json文件的格式,是一个 JSON 对象,最简单的情况可以只放置一个空对象{}。
1 | { |
tsconfig.json文件可以不必手写,使用 tsc 命令的–init参数自动生成。
也可以使用别人预先写好的 tsconfig.json 文件,npm 的@tsconfig名称空间下面有很多模块,都是写好的tsconfig.json样本,比如 @tsconfig/recommended和@tsconfig/node16。
25.2 exclude
exclude属性是一个数组,必须与include属性一起使用,用来从编译列表中去除指定的文件。它也支持使用与include属性相同的通配符。
1 | { |
25.3 extends
tsconfig.json可以继承另一个tsconfig.json文件的配置。如果一个项目有多个配置,可以把共同的配置写成tsconfig.base.json,其他的配置文件继承该文件,这样便于维护和修改。
extends属性用来指定所要继承的配置文件。它可以是本地文件。
1 | { |
如果extends属性指定的路径不是以./或../开头,那么编译器将在node_modules目录下查找指定的配置文件。
extends属性也可以继承已发布的 npm 模块里面的 tsconfig 文件。
1 | { |
extends指定的tsconfig.json会先加载,然后加载当前的tsconfig.json。如果两者有重名的属性,后者会覆盖前者。
25.4 files
files属性指定编译的文件列表,如果其中有一个文件不存在,就会报错。
它是一个数组,排在前面的文件先编译。
1 | { |
该属性必须逐一列出文件,不支持文件匹配。如果文件较多,建议使用include和exclude属性。
25.5 include
include属性指定所要编译的文件列表,既支持逐一列出文件,也支持通配符。文件位置相对于当前配置文件而定。
1 | { |
include属性支持三种通配符。
?:指代单个字符
*:指代任意字符,不含路径分隔符
**:指定任意目录层级。
如果不指定文件后缀名,默认包括.ts、.tsx和.d.ts文件。如果打开了allowJs,那么还包括.js和.jsx。
25.6 references
references属性是一个数组,数组成员为对象,适合一个大项目由许多小项目构成的情况,用来设置需要引用的底层项目。
1 | { |
references数组成员对象的path属性,既可以是含有文件tsconfig.json的目录,也可以直接是该文件。
与此同时,引用的底层项目的tsconfig.json必须启用composite属性。
1 | { |
25.7 compileOptions
compilerOptions属性用来定制编译行为。这个属性可以省略,这时编译器将使用默认设置。
25.7.1 allowJs
allowJs允许 TypeScript 项目加载 JS 脚本。编译时,也会将 JS 文件,一起拷贝到输出目录。
1 | { |
25.7.2 alwaysStrict
alwaysStrict确保脚本以 ECMAScript 严格模式进行解析,因此脚本头部不用写”use strict”。它的值是一个布尔值,默认为true。
25.7.3 allowSyntheticDefaultImports
allowSyntheticDefaultImports允许import命令默认加载没有default输出的模块。
比如,打开这个设置,就可以写import React from “react”;,而不是import * as React from “react”;。
25.7.4 allowUnreachableCode
allowUnreachableCode设置是否允许存在不可能执行到的代码。它的值有三种可能。
undefined: 默认值,编辑器显示警告。
true:忽略不可能执行到的代码。
false:编译器报错。
25.7.5 allowUnusedLabels
allowUnusedLabels设置是否允许存在没有用到的代码标签(label)。它的值有三种可能。
undefined: 默认值,编辑器显示警告。
true:忽略没有用到的代码标签。
false:编译器报错。
25.7.6 baseUrl
baseUrl的值为字符串,指定 TypeScript 项目的基准目录。
由于默认是以 tsconfig.json 的位置作为基准目录,所以一般情况不需要使用该属性。
1 | { |
25.7.7 checkJs
checkJS设置对 JS 文件同样进行类型检查。打开这个属性,也会自动打开allowJs。它等同于在 JS 脚本的头部添加// @ts-check命令。
1 | { |
25.7.8 composite
composite打开某些设置,使得 TypeScript 项目可以进行增量构建,往往跟incremental属性配合使用。
25.7.9 declaration
declaration设置编译时是否为每个脚本生成类型声明文件.d.ts。
1 | { |
25.7.10 declarationDir
declarationDir设置生成的.d.ts文件所在的目录。
1 | { |
25.7.11 declarationMap
declarationMap设置生成.d.ts类型声明文件的同时,还会生成对应的 SourceMap 文件。
1 | { |
25.7.12 emitBOM
emitBOM设置是否在编译结果的文件头添加字节顺序标志 BOM,默认值是false。
二十六、tsc 命令行编译器
26.1 简介
tsc 是 TypeScript 官方的命令行编译器,用来检查代码,并将其编译成 JavaScript 代码。
tsc 默认使用当前目录下的配置文件tsconfig.json,但也可以接受独立的命令行参数。命令行参数会覆盖tsconfig.json,比如命令行指定了所要编译的文件,那么tsc 就会忽略tsconfig.json的files属性。
tsc 的基本用法如下。
1 | # 使用 tsconfig.json 的配置 |
26.2 命令行参数
tsc 的命令行参数,大部分与 tsconfig.json 的属性一一对应。