简介
TypeScript(简称 TS)是微软公司开发的一种基于 JavaScript (简称 JS)语言的编程语言。
TypeScript 可以看成是 JavaScript 的超集(superset),即它继承了后者的全部语法。
TypeScript 对 JavaScript 添加的最主要部分,就是一个独立的类型系统。
优点:
- 有利于发现代码错误。
- 有助于代码重构。
- 更好的 IDE 支持,做到语法提示和自动补全。
综上所述,TypeScript 有助于提高代码质量,保证代码安全,更适合用在大型的企业级项目。
缺点:
- 丧失了动态类型的代码灵活性。
- 增加了编程工作量。
- 更高的学习成本。
- 引入了独立的编译步骤。
- 兼容性问题。(过去大多数的js项目没有typescript适配,需要手动适配)
总的来说,这些缺点使得 TypeScript 不一定适合那些小型的、短期的个人项目。
基本用法
类型声明
类型声明的写法,一律为在标识符后面添加“
冒号 + 类型
”。1
2
3
4
5
6
7
8
9
10// foo类型为string
let foo:string;
// 函数参数num类型为number,返回值为string
function toString(num:number):string {
return String(num);
}
// 报错,TypeScript 规定,变量只有赋值后才能使用
console.log(foo)
// 报错,变量的值与声明的类型不一致
foo = 123;类型推断
类型声明并不是必需的,如果没有,TypeScript 会自己推断类型。
1
2
3
4
5
6
7let foo = 123;
// 报错,推断类型是number
foo = 'hello';
// TypeScript 也可以推断函数的返回值
function toString(num:number) {
return String(num);
}从这里可以看到,TypeScript 的设计思想是,
类型声明是可选的
,你可以加,也可以不加。即使不加类型声明,依然是有效的 TypeScript 代码,只是这时不能保证 TypeScript 会正确推断出类型。编译
JavaScript 的运行环境(浏览器和 Node.js)不认识 TypeScript 代码。所以,TypeScript 项目要想运行,必须先转为 JavaScript 代码,这个代码转换的过程就叫做“编译”(compile)。
TypeScript 的类型检查只是编译时的类型检查,而不是运行时的类型检查。一旦代码编译为 JavaScript,运行时就不再检查类型了。
1
2
3
4
5
6
7
8
9# 安装 TypeScript 官方提供的编译器tsc
$ npm install -g typescript
# 检查版本
$ tsc -v
Version 5.1.6
# 编译脚本
$ tsc app.ts
# 编译多个脚本
$ tsc file1.ts file2.ts file3.tstsc常用参数:
–outFile:将多个 TypeScript 脚本编译成一个 JavaScript 文件
1
$ tsc file1.ts file2.ts --outFile app.js
–outDir:指定保存编译结果的目录(默认是当前目录)
1
$ tsc app.ts --outDir dist
–target:指定编译后的 JavaScript 版本(默认是
es3
,建议使用es2015
)1
$ tsc --target es2015 app.ts
–noEmitOnError:一旦报错就停止编译,不生成编译产物
1
$ tsc --noEmitOnError app.ts
–noEmit: 只检查类型是否正确,不生成 JavaScript 文件
1
$ tsc --noEmit app.ts
tsconfig.json
实际工作中并不经常使用命令参数的方式指定编译参数,因为编译参数还是很多的,对于项目而言一般是采用配置文件
tsconfig.json
。只要当前目录有这个文件,tsc
就会自动读取,所以运行时可以不写参数。1
2
3
4
5
6
7// tsc file1.ts file2.ts --outFile dist/app.js
{
"files": ["file1.ts", "file2.ts"],
"compilerOptions": {
"outFile": "dist/app.js"
}
}运行
ts-node 是一个非官方的 npm 模块,可以直接运行 TypeScript 代码。
1
2
3
4
5
6
7
8
9# 安装
$ npm install -g ts-node
# 运行
$ ts-node app.ts
# 不带参数会打开一个TypeScript 的 REPL 运行环境
$ ts-node
> const twice = (x:string) => x + x;
> twice('abc')
'abcabc'
三种特殊类型
any 类型,unknown 类型,never 类型
any 类型
any 类型表示没有任何限制,该类型的变量可以赋予任意类型的值。
1 | let x:any; |
实际开发中,any 类型主要适用以下两个场合。
(1)出于特殊原因,需要关闭某些变量的类型检查,就可以把该变量的类型设为 any。
(2)为了适配以前老的 JavaScript 项目,让代码快速迁移到 TypeScript,可以把变量类型设为 any。
总之,应该尽量避免使用 any 类型,否则就失去了使用 TypeScript 的意义,变成了戏称的 “AnyScript”
any 类型的两大问题:
(1)类型推断问题
TypeScript 对于无法推断出类型,就会认为该变量的类型是
any
。这显然是很糟糕的情况,因此 TypeScript 提供了一个编译选项noImplicitAny
,打开该选项,只要赋值的时候推断出any
类型就会报错。
1
2
3 # 注意只赋值时推断会出错,如果只是声明不赋值被推断成any不报错。
# 如 let x; var x;
$ tsc --noImplicitAny app.ts(2)污染问题
any
类型除了关闭类型检查,还有一个很大的问题,就是它会“污染”其他变量。它可以赋值给其他任何类型的变量(因为没有类型检查),导致其他变量出错。
1
2
3
4
5
6 let x:any = 'hello';
let y:number;
y = x; // 不报错
y * 123 // 不报错
y.toFixed() // 不报错
unknown 类型
为了解决any
类型“污染”其他变量的问题,TypeScript 3.0 引入了unknown
类型。它与any
含义相同,表示类型不确定,可能是任意类型,但是它的使用有一些限制,不像any
那样自由,可以视为严格版的any
。
unknown
跟any
的相似之处,在于所有类型的值都可以分配给unknown
类型。1
2
3
4
5let x:unknown;
x = true; // 正确
x = 42; // 正确
x = 'Hello World'; // 正确unknown
类型跟any
类型的不同之处在于,它不能直接使用。1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17// 1. unknown类型的变量,不能直接赋值给其他类型的变量(除了any类型和unknown类型)。
let v:unknown = 123;
let v1:boolean = v; // 报错
let v2:number = v; // 报错
// 2. 不能直接调用unknown类型变量的方法和属性。
let v1:unknown = { foo: 123 };
v1.foo // 报错
let v2:unknown = 'hello';
v2.trim() // 报错
let v3:unknown = (n = 0) => n + 1;
v3() // 报错
// 3. unknown类型变量能够进行的运算是有限的,只能进行比较运算(运算符==、===、!=、!==、||、&&、?)、取反运算(运算符!)、typeof运算符和instanceof运算符这几种,其他运算都会报错。
let a:unknown = 1;
a + 1 // 报错
a === 1 // 正确那么,怎么才能使用
unknown
类型变量呢?答案是只有经过“类型缩小”,
unknown
类型变量才可以使用。这样设计的目的是,只有明确unknown
变量的实际类型,才允许使用它,防止像any
那样可以随意乱用,“污染”其他变量。1
2
3
4let a:unknown = 1;
if (typeof a === 'number') {
let r = a + 10; // 正确
}总之,
unknown
可以看作是更安全的any
。一般来说,凡是需要设为any
类型的地方,通常都应该优先考虑设为unknown
类型。
never 类型
为了保持与集合论的对应关系,以及类型运算的完整性,TypeScript 还引入了“空类型”的概念,即该类型为空,不包含任何值。
由于不存在任何属于“空类型”的值,所以该类型被称为never
,即不可能有这样的值。
never 类型的使用场景,主要是在一些类型运算之中,保证类型运算的完整性,详见后面章节。另外,不可能返回值的函数,返回值的类型就可以写成 never,详见《函数》 一章。
1 let x:never;
never
类型的一个重要特点是,可以赋值给任意其他类型。
1
2
3
4
5
6
7 function f():never {
throw new Error('Error');
}
let v1:number = f(); // 不报错
let v2:string = f(); // 不报错
let v3:boolean = f(); // 不报错由于该类型的特殊性,平时用的极少,主要是杜绝后面使用该变量。
总之,TypeScript 有两个“顶层类型”(any
和unknown
),但是“底层类型”只有never
唯一一个。
类型系统
基本类型
TypeScript 继承了 JavaScript 的类型设计,Javascript 的8种基本类型可以看作 TypeScript 的基本类型。
1 | // 布尔类型 |
包装对象类型
JavaScript 的8种类型之中,undefined
和null
其实是两个特殊值,object
属于复合类型,剩下的五种属于原始类型(primitive value),代表最基本的、不可再分的值。
- boolean
- string
- number
- bigint
- symbol
上面这五种原始类型的值,都有对应的包装对象(wrapper object)。所谓“包装对象”,指的是这些值在需要时,会自动产生的对象(详见:包装对象类型) 。
1 | // s就是字符串hello的包装对象 |
为了区分字面量和包装对象,TypeScript 对五种原始类型分别提供了大写和小写两种类型。
- Boolean 和 boolean
- String 和 string
- Number 和 number
- BigInt 和 bigint
- Symbol 和 symbol
其中,大写类型同时包含包装对象和字面量两种情况,小写类型只包含字面量,不包含包装对象。
1 | const s1:String = 'hello'; // 正确 |
注意,
Symbol()
和BigInt()
这两个函数不能当作构造函数使用,所以没有办法直接获得 symbol 类型和 bigint 类型的包装对象. 目前在 TypeScript 里面,symbol
和Symbol
两种写法没有差异,bigint
和BigInt
也是如此,不知道是否属于官方的疏忽。总得来说,建议只使用小写类型,不使用大写类型。因为绝大部分使用原始类型的场合,都是使用字面量,不使用包装对象。而且,TypeScript 把很多内置方法的参数,定义成小写类型,使用大写类型会报错。
Object 和 object
TypeScript 的对象类型也有大写Object
和小写object
两种。
大写的Object
大写的Object
类型代表 JavaScript 语言里面的广义对象。所有可以转成对象的值,都是Object
类型,这囊括了几乎所有的值。
1 | let obj:Object; |
事实上,除了
undefined
和null
这两个值不能转为对象,其他任何值都可以赋值给Object
类型。空对象
{}
是Object
类型的简写形式
1
2 let obj:{};
obj = true;
小写的object
小写的object
类型代表 JavaScript 里面的狭义对象,即可以用字面量表示的对象,只包含对象、数组和函数,不包括原始类型的值。
1 | let obj:object; |
大多数时候,我们使用对象类型,只希望包含真正的对象,不希望包含原始类型。所以,建议总是使用小写类型
object
,不使用大写类型Object
。注意,无论是大写的
Object
类型,还是小写的object
类型,都只包含 JavaScript 内置对象原生的属性和方法,用户自定义的属性和方法都不存在于这两个类型之中。如何描述对象的自定义属性,详见《对象类型》一章。
1
2
3 const o1:Object = { foo: 0 };
o1.toString() // 正确
o1.foo // 报错
undefined 和 null
undefined
和null
既是值,又是类型。
作为值,它们有一个特殊的地方:任何其他类型的变量都可以赋值为undefined
或null
。
1 | let age:number = 24; |
如此就会导致age后面使用number的方法(如toFixed)的时候在编译阶段不报错,在运行阶段就报错了。为了避免这种情况,及早发现错误,TypeScript 提供了一个编译选项strictNullChecks
。只要打开这个选项,undefined
和null
只能赋值给自身,或者any
类型和unknown
类型的变量。
原因:JavaScript 的行为是,变量如果等于
undefined
就表示还没有赋值,如果等于null
就表示值为空。所以,默认情况下,TypeScript 就允许了任何类型的变量都可以赋值为这两个值。
值类型
TypeScript 规定,单个值也是一种类型,称为“值类型”。
1 | let x:'hello'; |
TypeScript 推断类型时,遇到
const
命令声明的变量,如果代码里面没有注明类型,就会推断该变量是值类型。
1
2
3
4
5
6
7
8
9 // x 的类型是 "https"
const x = 'https';
// y 的类型是 string
const y:string = 'https';
// 注意,const命令声明的变量,如果赋值为对象,并不会推断为值类型。
// x 的类型是 { foo: number }
const x = { foo: 1 };值类型可能会出现一些很奇怪的报错。
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
,这样就不会报错了。只包含单个值的值类型,用处不大。实际开发中,往往将多个值结合,作为联合类型使用。
联合类型
联合类型(union types)指的是多个类型组成的一个新类型,使用符号|
表示。
联合类型A|B
表示,任何一个类型只要属于A
或B
,就属于联合类型A|B
。
1 | let x:string|number; |
上面示例中,变量x
就是联合类型string|number
,表示它的值既可以是字符串,也可以是数值。
联合类型可以与值类型相结合,表示一个变量的值有若干种可能。
1
2
3 let setting:true|false;
let gender:'male'|'female';
let rainbowColor:'赤'|'橙'|'黄'|'绿'|'青'|'蓝'|'紫';联合类型的第一个成员前面,也可以加上竖杠
|
,这样便于多行书写。
1
2
3
4
5 let x:
| 'one'
| 'two'
| 'three'
| 'four';前面提到,打开编译选项
strictNullChecks
后,其他类型的变量不能赋值为undefined
或null
。这时,如果某个变量确实可能包含空值,就可以采用联合类型的写法。
1
2
3
4 let name:string|null;
name = 'John';
name = null;如果一个变量有多种类型,读取该变量时,往往需要进行“类型缩小”(type narrowing),区分该值到底属于哪一种类型,然后再进一步处理。
1
2
3
4
5
6
7 function printId(id:number|string) {
if (typeof id === 'string') {
console.log(id.toUpperCase());
} else {
console.log(id);
}
}
交叉类型
交叉类型(intersection types)指的多个类型组成的一个新类型,使用符号&
表示。
交叉类型A&B
表示,任何一个类型必须同时属于A
和B
,才属于交叉类型A&B
,即交叉类型同时满足A
和B
的特征。
1 | let x:number&string; |
上面示例中,变量x
同时是数值和字符串,这当然是不可能的,所以 TypeScript 会认为x
的类型实际是never
。
交叉类型的主要用途是表示对象的合成,类似属性类型相交。
1
2
3
4
5
6
7
8 let obj:
{ foo: string } &
{ bar: string };
obj = {
foo: 'hello',
bar: 'world'
};
类型别名
type
命令用来定义一个类型的别名。别名可以让类型的名字变得更有意义,也能增加代码的可读性,还可以使复杂类型用起来更方便,便于以后修改变量的类型。
1 | type Age = number; |
别名不允许重名
1
2 type Color = 'red';
type Color = 'blue'; // 报错别名的作用域是块级作用域
1
2
3
4
5
6
7
8 type T = boolean;
if (true) {
type T = number;
let v:T = 5;
} else {
type T = string;
let v:T = 'hello';
}别名支持使用表达式和嵌套
1
2 type World = "world";
type Greeting = `hello ${World}`;
类型兼容
TypeScript 的类型存在兼容关系,某些类型可以兼容其他类型。
1 | type T = number|string; |
TypeScript 为这种情况定义了一个专门术语。如果类型A
的值可以赋值给类型B
,那么类型A
就称为类型B
的子类型(subtype)。在上例中,类型number
就是类型number|string
的子类型。
TypeScript 的一个规则是,凡是可以使用父类型的地方,都可以使用子类型,但是反过来不行。
1
2
3
4
5 let a:'hi' = 'hi';
let b:string = 'hello';
b = a; // 正确
a = b; // 报错之所以有这样的规则,是因为子类型继承了父类型的所有特征,所以可以用在父类型的场合。但是,子类型还可能有一些父类型没有的特征,所以父类型不能用在子类型的场合。
typeof
TypeScript 将typeof
运算符移植到了类型运算,与 JavaScript 相比,它的操作数依然是一个值,但是返回的不是字符串,而是该值的 TypeScript 类型。
1 | const a = { x: 0 }; |
要区分 Typescript 和 JavaScript 的 typeof
1
2
3
4
5
6
7
8 let a = 1;
// typescript 的 typeof, 类型运算,编译后会删除
let b:typeof a;
// javascript 的 typeof,值运算,编译后会保留
if (typeof a === 'number') {
b = a;
}Typescript 的 typeof 参数只能是标识符
1
2 // 编译时不会进行 JavaScript 的值运算
type T = typeof Date(); // 报错Typescript 的 typeof 命令参数不能是类型
1
2 type Age = number;
type MyAge = typeof Age; // 报错是“类型缩小”的一种方式
数组类型
TypeScript 数组有一个根本特征:所有成员的类型必须相同,但是成员数量是不确定的,可以是无限数量的成员,也可以是零成员。
1 | // arr数组成员类型是number |
数组类型声明了以后,成员数量是不限制的,任意数量的成员都可以,也可以是空数组。
1
2
3
4
5 let arr:number[];
arr = [];
arr = [1];
arr = [1, 2];
arr = [1, 2, 3];由于成员数量可以动态变化,所以 TypeScript 不会对数组边界进行检查,越界访问数组并不会报错。
1
2 let arr:number[] = [1, 2, 3];
let foo = arr[3]; // 正确,类型推导是undefined读取数组成员类型
1
2
3
4
5
6 type Names = string[];
// 第一种方法:
type Name = Names[0]; // string
// 第二种方法(因为数组成员的索引类型都是number):
type Name = Names[number]; // string数组的类型推断
1
2
3
4
5
6
7
8 // 1. 数组初始值为空数组时,类型推断自动更新
const arr = []; // 推断为 any[]
arr.push(123); // 推断类型为 number[]
arr.push('abc'); // 推断类型为 (string|number)[]
// 2. 数组初始值不为空时,类型推断不会更新
const arr = [123]; // 推断类型为 number[]
arr.push('abc'); // 报错只读数组
JavaScript 中,const 命令声明的数组变量是可以改变成员的。
TypeScript 中,要限定声明数组只读,可以在数组类型前加上
readonly
关键字。
1
2
3
4
5 const arr:readonly number[] = [0, 1];
// 如果不加 readonly 下面则不报错
arr[1] = 2; // 报错
arr.push(3); // 报错
delete arr[0]; // 报错TypeScript 将
readonly number[]
与number[]
视为两种不一样的类型,后者是前者的子类型。我们知道,子类型继承了父类型的所有特征,并加上了自己的特征,所以子类型
number[]
可以用于所有使用父类型的场合,反过来就不行。
1
2
3
4 let a1:number[] = [0, 1];
let a2:readonly number[] = a1; // 正确
a1 = a2; // 报错注意,
readonly
关键字不能与数组的泛型写法一起使用。
1
2
3
4
5 const arr:readonly Array<number> = [0, 1]; // 报错
// TypeScript 提供了两个专门的泛型,用来生成只读数组的类型。
const a1:ReadonlyArray<number> = [0, 1];
const a2:Readonly<number[]> = [0, 1];只读数组还有一种声明方法,就是使用“const 断言”。
1
2 const arr = [0, 1] as const;
arr[0] = [2]; // 报错多维数组
1
2
3 // 这里是表示一个二维数组
var multi:number[][] =
[[1,2,3], [23,24,25]];
元组类型
元组(tuple)是 TypeScript 特有的数据类型,JavaScript 没有单独区分这种类型。它表示成员类型可以自由设置的数组,即数组的各个成员的类型可以不同。
由于成员的类型可以不一样,所以元组必须明确声明每个成员的类型。
1 | const s:[string, string, boolean] |
类型的自动推断
使用元组时,必须明确给出类型声明(上例的
[number]
),不能省略,否则 TypeScript 会把一个值自动推断为数组。
1
2 // a 的类型被推断为 (number | boolean)[]
let a = [1, true];可选成员
元组成员的类型可以添加问号后缀(
?
),表示该成员是可选的。
1
2
3
4
5
6
7
8
9 let a:[number, number?] = [1];
// 注意,问号只能用于元组的尾部成员,也就是说,所有可选成员必须在必选成员之后。
type myTuple = [
number,
number,
number?,
string?
];不能越界
由于需要声明每个成员的类型,所以大多数情况下,元组的成员数量是有限的,从类型声明就可以明确知道,元组包含多少个成员,越界的成员会报错。
1
2
3 let x:[string, string] = ['a', 'b'];
x[2] = 'c'; // 报错扩展运算符
使用扩展运算符(
...
),可以表示不限成员数量的元组。
1
2
3
4
5
6
7 type NamedNums = [
string,
...number[]
];
const a:NamedNums = ['A', 1, 2];
const b:NamedNums = ['B', 1, 2, 3];扩展运算符(
...
)用在元组的任意位置都可以,它的后面只能是一个数组或元组。
1
2
3 type t1 = [string, number, ...boolean[]];
type t2 = [string, ...boolean[], number];
type t3 = [...boolean[], string, number];成员名
元组的成员可以添加成员名,这个成员名是说明性的,可以任意取名,没有实际作用。
1
2
3
4
5
6
7 type Color = [
red: number,
green: number,
blue: number
];
const c:Color = [255, 255, 255];读取成员类型
1
2
3 // 读取对应位置成员类型
type Tuple = [string, number];
type Age = Tuple[1]; // number// 读取整个元组的成员类型(由于元组的成员都是数值索引,即索引类型都是number)
type Tuple = [string, number, Date];
type TupleEl = Tuple[number]; // string|number|Date
1
2
3
4
5
6
7
8
9
10
11
7. 只读元组
元组也可以是只读的,不允许修改,有两种写法。
```typescript
// 写法一
type t = readonly [number, string]
// 写法二
type t = Readonly<[number, string]>跟数组一样,只读元组是元组的父类型。所以,元组可以替代只读元组,而只读元组不能替代元组。
1
2
3
4
5
6
7 type t1 = readonly [number, number];
type t2 = [number, number];
let x:t2 = [1, 2];
let y:t1 = x; // 正确
x = y; // 报错只读元组的其他写法
1 const arr = [0, 1] as const;在上一章讲到,生成的是只读数组,其实生成的同时也是只读元组。因为它生成的实际上是一个只读的“值类型”
readonly [3, 4]
,把它解读成只读数组或只读元组都可以。
成员数量的推断
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15 // 1. 如果没有可选成员和扩展运算符,TypeScript 会推断出元组的成员数量(即元组长度)。
function f(point: [number, number]) {
if (point.length === 3) { // 报错
// ...
}
}
// 2. 如果包含了可选成员,TypeScript 会推断出可能的成员数量。
function f(
point:[number, number?, number?]
) {
if (point.length === 4) { // 报错
// ...
}
}// 3. 如果使用了扩展运算符,TypeScript 就无法推断出成员数量。
const myTuple:[…string[]]
= [‘a’, ‘b’, ‘c’];
if (myTuple.length === 4) { // 正确
// …
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
9. 扩展运算符与成员数量
扩展运算符(`...`)将数组(注意,不是元组)转换成一个逗号分隔的序列,这时 TypeScript 会认为这个序列的成员数量是不确定的,因为数组的成员数量是不确定的。
这导致如果函数调用时,使用扩展运算符传入函数参数,可能发生参数数量与数组长度不匹配的报错。
```typescript
const arr = [1, 2];
function add(x:number, y:number){
// ...
}
add(...arr) // 报错上面示例会报错,原因是函数
add()
只能接受两个参数,但是传入的是...arr
,TypeScript 认为转换后的参数个数是不确定的。解决这个问题的一个方法,就是把成员数量不确定的数组,写成成员数量确定的元组,再使用扩展运算符。
1
2
3
4
5
6
7
8
9 const arr:[number, number] = [1, 2];
// 或者
// const arr = [1, 2] as const;
function add(x:number, y:number){
// ...
}
add(...arr) // 正确
symbol 类型
Symbol 是 ES2015 新引入的一种原始类型的值。它类似于字符串,但是每一个 Symbol 值都是独一无二的,与其他任何值都不相等。
Symbol 值通过Symbol()
函数生成。在 TypeScript 里面,Symbol 的类型使用symbol
表示。
1 | let x:symbol = Symbol(); |
unique symbol
symbol
类型包含所有的 Symbol 值,但是无法表示某一个具体的 Symbol 值。
比如,5
是一个具体的数值,就用5
这个字面量来表示,这也是它的值类型。但是,Symbol 值不存在字面量,必须通过变量来引用,所以写不出只包含单个 Symbol 值的那种值类型。
为了解决这个问题,TypeScript 设计了symbol
的一个子类型unique symbol
,它表示单个的、某个具体的 Symbol 值。
因为unique symbol
表示单个值,所以这个类型的变量是不能修改值的,只能用const
命令声明,不能用let
声明。
1 | // 正确 |
unique symbol 声明特定情况下可省略
const
命令为变量赋值 Symbol 值时,变量类型默认就是unique symbol
,所以类型可以省略不写。
1
2
3 const x:unique symbol = Symbol();
// 等同于
const x = Symbol();每个 unique symbol 类型变量的值不同
每个声明为
unique symbol
类型的变量,它们的值都是不一样的,其实属于两个值类型。
1
2
3
4 const a:unique symbol = Symbol();
const b:unique symbol = Symbol();
a === b // 报错上面示例中,变量
a
和变量b
的类型虽然都是unique symbol
,但其实是两个值类型。不同类型的值肯定是不相等的,所以最后一行就报错了。由于 Symbol 类似于字符串,可以参考下面的例子来理解。
1
2
3
4 const a:'hello' = 'hello';
const b:'world' = 'world';
a === b // 报错每个 unique symbol 类型不能互相赋值
由于变量
a
和b
是两个类型,就不能把一个赋值给另一个。
1
2
3
4
5 const a:unique symbol = Symbol();
const b:unique symbol = a; // 报错
// 要写成与变量a同一个unique symbol值类型的话
const b:typeof a = a; // 正确unique symbol 类型是 symbol 类型的子类型
1
2
3
4 const a:unique symbol = Symbol();
const b:symbol = a; // 正确
const c:unique symbol = b; // 报错unique symbol 类型的作用
unique symbol 类型的一个作用,就是用作属性名,这可以保证不会跟其他属性名冲突。如果要把某一个特定的 Symbol 值当作属性名,那么它的类型只能是 unique symbol,不能是 symbol。
1
2
3
4
5
6
7 const x:unique symbol = Symbol();
const y:symbol = Symbol();
interface Foo {
[x]: string; // 正确
[y]: string; // 报错
}
unique symbol
类型也可以用作类(class)的属性值,但只能赋值给类的readonly static
属性。
1
2
3
4 class C {
// 注意,这时static和readonly两个限定符缺一不可,这是为了保证这个属性是固定不变的。
static readonly foo:unique symbol = Symbol();
}类型推断
如果变量声明时没有给出类型,TypeScript 会推断某个 Symbol 值变量的类型。
1
2
3
4
5
6
7
8
9 // let命令声明的变量,推断类型为 symbol。
let x = Symbol();
// const命令声明的变量,推断类型为 unique symbol。
const y = Symbol();
// const命令声明的变量,如果赋值为另一个 symbol 类型的变量,则推断类型为 symbol。
const z = x;
// let命令声明的变量,如果赋值为另一个 unique symbol 类型的变量,则推断类型还是 symbol。
let t = y;
函数类型
简介
函数的类型声明,需要在声明函数时,给出参数的类型和返回值的类型。
1 | function hello( |
参数类型推断:如果不指定参数类型(比如上例不写txt
的类型),TypeScript 就会推断参数类型,如果缺乏足够信息,就会推断该参数的类型为any
。
返回值的类型推断:返回值的类型通常可以不写,因为 TypeScript 自己会推断出来。(不过,有时候出于文档目的,或者为了防止不小心改掉返回值,还是会写返回值的类型。)
如果变量被赋值为一个函数,变量的类型有两种写法。
1 | // 写法一:通过等号右边的函数类型,推断出变量hello的类型; |
写法二注意事项:
函数类型里面的参数名与实际参数名,可以不一致。
1
2
3
4
5
6 let f:(x:number) => number;
// 这里参数名由x改为y了
f = function (y:number) {
return y;
};如果函数的类型定义很冗长,或者多个函数使用同一种类型,写法二用起来就很麻烦。因此,往往用
type
命令为函数类型定义一个别名,便于指定给其他变量。
1
2
3
4
5 type MyFunc = (txt:string) => void;
const hello:MyFunc = function (txt) {
console.log('hello ' + txt);
};函数的实际参数个数,可以少于类型指定的参数个数,但是不能多于,即 TypeScript 允许省略参数。
1
2
3
4
5
6
7
8 let myFunc:
(a:number, b:number) => number;
myFunc = (a:number) => a; // 正确
myFunc = (
a:number, b:number, c:number
) => a + b + c; // 报错这是因为 JavaScript 函数在声明时往往有多余的参数,实际使用时可以只传入一部分参数。比如,数组的
forEach()
方法的参数是一个函数,该函数默认有三个参数(item, index, array) => void
,实际上往往只使用第一个参数(item) => void
。因此,TypeScript 允许函数传入的参数不足。函数参数少的可以赋值给函数参数多的变量
相当于函数参数少的是子类,多的是父类
1
2
3
4
5 let x = (a:number) => 0;
let y = (b:number, s:string) => 0;
y = x; // 正确
x = y; // 报错如果一个变量要套用另一个函数类型,有一个小技巧,就是使用
typeof
运算符。
1
2
3
4
5
6
7
8
9
10 function add(
x:number,
y:number
) {
return x + y;
}
const myAdd:typeof add = function (x, y) {
return x + y;
}这是一个很有用的技巧,任何需要类型的地方,都可以使用
typeof
运算符从一个值获取类型。函数类型还可以采用对象的写法。
1
2
3
4
5
6
7 let add:{
(x:number, y:number):number
};
add = function (x, y) {
return x + y;
};上面示例中,变量
add
的类型就写成了一个对象。这种写法平时很少用,但是非常合适用在一个场合:函数本身存在属性。
1
2
3
4
5
6
7
8
9 function f(x:number) {
console.log(x);
}
f.version = '1.0';
let foo: {
(x:number): void;
version: string
} = f;函数类型也可以使用 Interface 来声明,这种写法就是对象写法的翻版
1
2
3
4
5 interface myfn {
(a:number, b:number): number;
}
var add:myfn = (a, b) => a + b;
Function类型
TypeScript 提供 Function 类型表示函数,任何函数都属于这个类型。
1 | function doSomething(f:Function) { |
Function 类型的函数可以接受任意数量的参数,每个参数的类型都是any
,返回值的类型也是any
,代表没有任何约束,所以不建议使用这个类型,给出函数详细的类型声明会更好。
箭头函数
箭头函数是普通函数的一种简化写法,它的类型写法与普通函数类似。
1 | const repeat = ( |
可选参数
@todo:是否和对象可选属性一样开启
ExactOptionalPropertyTypes
和strictNullChecks
,可选属性就不能设为undefined
。
如果函数的某个参数可以省略,则在参数名后面加问号表示。
1 | function f(x?:number) { |
参数名带有问号,表示该参数的类型实际上是原始类型|undefined
,它有可能为undefined
。比如,上例的x
虽然类型声明为number
,但是实际上是number|undefined
。
函数的可选参数只能在参数列表的尾部,跟在必选参数的后面。
1
2
3
4
5
6 let myFunc:
(a?:number, b:number) => number; // 报错
// 如果前部参数有可能为空,这时只能显式注明该参数类型可能为undefined。
let myFunc:
(a?:number|undefined,, b:number) => number; // OK函数体内部用到可选参数时,需要判断该参数是否为
undefined
。
1
2
3
4
5
6
7 let myFunc:
(a:number, b?:number) => number;
myFunc = function (x, y) {
if (y === undefined) return x;
return x + y;
}
参数默认值
TypeScript 函数的参数默认值写法,与 JavaScript 一致。
设置了默认值的参数,就是可选的。如果不传入该参数,它就会等于默认值。
1 | function createPoint( |
可选参数与默认值不能同时使用。
1
2 // 报错
function f(x?: number = 0) { }具有默认值的参数如果不位于参数列表的末尾,调用时不能省略
1
2
3
4
5
6 function add(x:number = 0, y:number) {
return x + y;
}
add(1) // 报错
add(undefined, 1) // 正确
参数解构
函数参数如果存在变量解构,类型写法如下。
1 | function sumArr( |
参数解构可以结合类型别名(type 命令)一起使用,代码会看起来简洁一些。
1
2
3
4
5 type ABC = { a:number; b:number; c:number };
function sum({ a, b, c }:ABC) {
console.log(a + b + c);
}
reset参数
rest 参数表示函数剩余的所有参数,它可以是数组(剩余参数类型相同),也可能是元组(剩余参数类型不同)。
1 | // rest 参数为数组 |
元组需要声明每一个剩余参数的类型。如果元组里面的参数是可选的,则要使用可选参数。
1
2
3 function f(
...args: [boolean, string?]
) {}rest 参数甚至可以嵌套。
1
2
3 function f(...args:[boolean, ...string[]]) {
// ...
}rest 参数可以与变量解构结合使用。
1
2
3
4
5
6
7
8
9
10
11
12
13 function repeat(
...[str, times]: [string, number]
):string {
return str.repeat(times);
}
// 等同于
function repeat(
str: string,
times: number
):string {
return str.repeat(times);
}
readonly 参数
如果函数内部不能修改某个参数,可以在函数定义时,在参数类型前面加上readonly
关键字,表示这是只读参数。
1 | function arraySum( |
void 类型
void 类型表示函数没有返回值。
1 | function f():void { |
除了函数,其他变量声明为
void
类型没有多大意义,因为这时只能赋值为undefined
或者null
。
1
2
3
4 let foo:void = undefined;
// 没有打开 strictNullChecks 的情况下
let bar:void = null;void 类型允许返回
undefined
或null
。
1
2
3
4
5
6
7
8 function f():void {
return undefined; // 正确
}
// 没有打开 strictNullChecks 的情况下
function f():void {
return null; // 正确
}如果变量、对象方法、函数参数是一个返回值为 void 类型的函数,该变量、对象方法和函数参数可以接受返回任意值的函数,这时并不会报错(除非后续使用了该函数)。
1
2
3
4
5
6
7 type voidFunc = () => void;
const f:voidFunc = () => {
return 123; // 正确
};
f() * 2 // 报错这是因为,这时 TypeScript 认为,这里的 void 类型只是表示该函数的返回值没有利用价值,或者说不应该使用该函数的返回值。只要不用到这里的返回值,就不会报错。(举例来说,数组方法
Array.prototype.forEach(fn)
的参数fn
是一个函数,而且这个函数应该没有返回值,即返回值类型是void
。但是,实际应用中,很多时候传入的函数是有返回值,但是它的返回值不重要,或者不产生作用。)
never 类型
never
类型表示肯定不会出现的值。它用在函数的返回值,就表示某个函数肯定不会返回值,即函数不会正常执行结束。
它主要有以下两种情况。
抛出错误的函数。
1
2
3function fail(msg:string):never {
throw new Error(msg);
}注意,只有抛出错误,才是 never 类型。如果显式用
return
语句返回一个 Error 对象,返回值就不是 never 类型。1
2
3function fail():Error {
return new Error("Something failed");
}无限执行的函数。
1
2
3
4
5const sing = function():never {
while (true) {
console.log('sing');
}
};
关于never函数的一些说明:
never
类型不同于void
类型。前者表示函数没有执行结束,不可能有返回值;后者表示函数正常执行结束,但是不返回值,或者说返回undefined
。1
2
3
4
5
6
7
8
9// 正确
function sing():void {
console.log('sing');
}
// 报错
function sing():never {
console.log('sing');
}如果一个函数抛出了异常或者陷入了死循环,那么该函数无法正常返回一个值,因此该函数的返回值类型就是
never
。如果程序中调用了一个返回值类型为never
的函数,那么就意味着程序会在该函数的调用位置终止,永远不会继续执行后续的代码。一个函数如果某些条件下有正常返回值,另一些条件下抛出错误,这时它的返回值类型可以省略
never
。1
2
3
4
5
6
7
8
9function sometimesThrow():number {
if (Math.random() > 0.5) {
return 100;
}
throw new Error('Something went wrong');
}
const result = sometimesThrow();原因是前面章节提到过,
never
是 TypeScript 的唯一一个底层类型,所有其他类型都包括了never
。从集合论的角度看,number|never
等同于number
。这也提示我们,函数的返回值无论是什么类型,都可能包含了抛出错误的情况。
高阶函数
一个函数的返回值还是一个函数,那么前一个函数就称为高阶函数(higher-order function)。
1 | const fn = (someValue: number) => (multiplier: number) => someValue * multiplier; |
函数重载
有些函数可以接受不同类型或不同个数的参数,并且根据参数的不同,会有不同的函数行为。这种根据参数类型不同,执行不同逻辑的行为,称为函数重载(function overload)。
1 | reverse('abc') // 'cba' |
上面示例中,函数reverse()
可以将参数颠倒输出。参数可以是字符串,也可以是数组。
这意味着,该函数内部有处理字符串和数组的两套逻辑,根据参数类型的不同,分别执行对应的逻辑。这就叫“函数重载”。
TypeScript 对于“函数重载”的类型声明方法是,逐一定义每一种情况的类型,然后给予完整的类型声明。
1 | function reverse(str:string):string; // 定义函数重载情况1 |
重载的各个类型描述与函数的具体实现之间,不能有其他代码,否则报错。
虽然函数的具体实现里面,有完整的类型声明。但是,函数实际调用的类型,以前面的类型声明为准。比如,上例的函数实现,参数类型和返回值类型都是
string|any[]
,但不意味着参数类型为string
时返回值类型为any[]
。重载声明的排序很重要
因为 TypeScript 是按照顺序进行检查的,一旦发现符合某个类型声明,就不再往下检查了,所以类型最宽的声明应该放在最后面,防止覆盖其他类型声明。
1
2
3
4
5
6
7 function f(x:any):number;
function f(x:string): 0|1;
function f(x:any):any {
// ...
}
const a:0|1 = f('hi'); // 报错对象的方法也可以使用重载。
1
2
3
4
5
6
7
8
9
10
11 class StringBuilder {
#data = '';
add(num:number): this;
add(bool:boolean): this;
add(str:string): this;
add(value:any): this {
this.#data += String(value);
return this;
}
}函数重载不仅仅可以通过 同名函数的形式参数(指参数的个数、类型或者顺序)不同,还可通过 返回类型的不同 来重载函数。
1
2
3
4
5
6
7
8
9
10
11
12
13
14 function createElement(
tag:'a'
):HTMLAnchorElement;
function createElement(
tag:'canvas'
):HTMLCanvasElement;
function createElement(
tag:'table'
):HTMLTableElement;
function createElement(
tag:string
):HTMLElement {
// ...
}上面示例的函数重载,也可以用对象表示。(不过,实际上在 TypeScript 中,我们并不真正写多个重载的函数定义,而是写一个函数实现,具体可参考 interface接口重载一节。)
1
2
3
4
5
6 type CreateElement = {
(tag:'a'): HTMLAnchorElement;
(tag:'canvas'): HTMLCanvasElement;
(tag:'table'): HTMLTableElement;
(tag:string): HTMLElement;
}
总的来说,由于重载是一种比较复杂的类型声明方法,为了降低复杂性,一般来说,如果可以的话,应该优先使用联合类型替代函数重载,除非多个参数之间、或者某个参数与返回值之间,存在对应关系。
构造函数
JavaScript 语言使用构造函数,生成对象的实例。
构造函数的最大特点,就是必须使用new
命令调用。
1 | class Animal { |
构造函数还有另一种类型写法,就是采用对象形式。
1
2
3 type F = {
new (s:string): object;
};某些函数既是构造函数,又可以当作普通函数使用,比如
Date()
。这时,类型声明可以写成下面这样。
1
2
3
4 type F = {
new (s:string): object;
(n?:number): number;
}
对象类型
简介
除了原始类型,对象是 JavaScript 最基本的数据结构。TypeScript 对于对象类型有很多规则。
对象类型有三种写法:
1 | // 写法一:直接使用大括号 |
一旦声明了类型,对象赋值时,就不能缺少指定的属性,也不能有多余的属性。
1
2
3
4
5
6
7
8 type MyObj = {
x:number;
y:number;
};
const o1:MyObj = { x: 1 }; // 报错
const o2:MyObj = { x: 1, y: 1, z: 1 }; // 报错
const o3:MyObj = { x: 1, y: 1 }; // 正确读写不存在的属性也会报错。
1
2
3
4
5
6
7 const obj:{
x:number;
y:number;
} = { x: 1, y: 1 };
console.log(obj.z); // 报错
obj.z = 1; // 报错不能删除类型声明中存在的属性。
1
2
3
4
5 const myUser = { // myUser类型由 typescript 自动推断
name: "Sabrina",
};
delete myUser.name // 报错对象类型可以使用方括号读取属性的类型。
1
2
3
4
5 type User = {
name: string,
age: number
};
type Name = User['name']; // string
可选属性
如果某个属性是可选的(即可以忽略),需要在属性名后面加一个问号。
1 | const obj: { |
TypeScript 提供编译设置
ExactOptionalPropertyTypes
,只要同时打开这个设置和strictNullChecks
,可选属性就不能设为undefined
。
1
2
3
4
5 // 打开 ExactOptionsPropertyTypes 和 strictNullChecks
const obj: {
x: number;
y?: number;
} = { x: 1, y: undefined }; // 报错注意,可选属性与允许设为
undefined
的必选属性是不等价的。
1
2
3
4
5 type A = { x:number, y?:number };
type B = { x:number, y:number|undefined };
const ObjA:A = { x: 1 }; // 正确
const ObjB:B = { x: 1 }; // 报错读取可选属性之前,必须检查一下是否为
undefined
。
1
2
3
4
5
6
7
8
9 // 写法一: 三目运算符
let firstName = (user.firstName === undefined)
? 'Foo' : user.firstName;
let lastName = (user.lastName === undefined)
? 'Bar' : user.lastName;
// 写法二: 空值合并运算符 https://developer.mozilla.org/zh-CN/docs/Web/JavaScript/Reference/Operators/Nullish_coalescing
let firstName = user.firstName ?? 'Foo';
let lastName = user.lastName ?? 'Bar';
只读属性
属性名前面加上readonly
关键字,表示这个属性是只读属性,不能修改。
1 | const person:{ |
只读属性只能在对象初始化期间赋值,此后就不能修改该属性。
如果属性值是一个对象,
readonly
修饰符并不禁止修改该对象的属性,只是禁止完全替换掉该对象。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19 interface Home {
readonly resident: {
name: string;
age: number
};
}
const h:Home = {
resident: {
name: 'Vicky',
age: 42
}
};
h.resident.age = 32; // 正确
h.resident = {
name: 'Kate',
age: 23
} // 报错如果一个对象有两个引用,即两个变量对应同一个对象,其中一个变量是可写的,另一个变量是只读的,那么从可写变量修改属性,会影响到只读变量。
PS:在 TypeScript 中,当你将一个对象赋值给另一个类型兼容的对象变量时,并不会改变原对象的类型。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19 interface Person {
name: string;
age: number;
}
interface ReadonlyPerson {
readonly name: string;
readonly age: number;
}
let w:Person = {
name: 'Vicky',
age: 42,
};
let r:ReadonlyPerson = w;
w.age += 1;
r.age // 43如果希望属性值是只读的,除了声明时加上
readonly
关键字,还有一种方法,就是在赋值时,在对象后面加上只读断言as const
。
1
2
3
4
5 const myUser = {
name: "Sabrina",
} as const;
myUser.name = "Cynthia"; // 报错注意,上面的
as const
属于 TypeScript 的类型推断,如果变量明确地声明了类型,那么 TypeScript 会以声明的类型为准。
1
2
3
4
5 const myUser:{ name: string } = {
name: "Sabrina",
} as const;
myUser.name = "Cynthia"; // 正确
属性索引
如果对象的属性非常多,一个个声明类型就很麻烦,而且有些时候,无法事前知道对象会有多少属性,比如外部 API 返回的对象。这时 TypeScript 允许采用属性名表达式的写法来描述类型,称为“属性名的索引类型”。
索引类型里面,最常见的就是属性名的字符串索引。
1 | // 不管这个对象有多少属性,只要属性名为字符串,且属性值也是字符串,就符合这个类型声明。 |
JavaScript 对象的属性名(即上例的
property
)的类型有三种可能,除了上例的string
,还有number
和symbol
。
1
2
3
4
5
6
7 type T1 = {
[property: number]: string
};
type T2 = {
[property: symbol]: string
};对象可以同时有多种类型的属性名索引,比如同时有数值索引和字符串索引。但是,数值索引不能与字符串索引发生冲突,必须服从后者,这是因为在 JavaScript 语言内部,所有的数值属性名都会自动转为字符串属性名。
1
2
3
4 type MyType = {
[x: number]: boolean; // 报错,只能同为string
[x: string]: string;
}同样地,可以既声明属性名索引,也声明具体的单个属性名。如果单个属性名不符合属性名索引的范围,两者发生冲突,就会报错。
1
2
3
4 type MyType = {
foo: boolean; // 报错,只能同为string
[x: string]: string;
}属性的索引类型写法,建议谨慎使用,因为属性名的声明太宽泛,约束太少。另外,属性名的数值索引不宜用来声明数组,因为采用这种方式声明数组,就不能使用各种数组方法以及
length
属性,因为类型里面没有定义这些东西。
1
2
3
4
5
6 type MyArr = {
[n:number]: number;
};
const arr:MyArr = [1, 2, 3];
arr.length // 报错
解构赋值
解构赋值用于直接从对象中提取属性。
1 | const {id, name, price} = product; |
解构赋值的类型写法,跟为对象声明类型是一样的。
1 | const {id, name, price}:{ |
注意,目前没法为解构变量指定类型,因为对象解构里面的冒号,JavaScript 指定了其他用途,所以不得不写成上面那样,这一点需要特别小心,Typescript中很容易搞混这一点。
1
2
3
4
5 let { x: foo, y: bar } = obj;
// 等同于
let foo = obj.x;
let bar = obj.y;
结构类型原则
只要对象 B 满足 对象 A 的结构特征,TypeScript 就认为对象 B 兼容对象 A 的类型,这称为“结构类型”原则(structural typing)。
1 | type A = { |
根据“结构类型”原则,TypeScript 检查某个值是否符合指定类型时,并不是检查这个值的类型名(即“名义类型”),而是检查这个值的结构是否符合要求(即“结构类型”)。
TypeScript 之所以这样设计,是为了符合 JavaScript 的行为。JavaScript 并不关心对象是否严格相似,只要某个对象具有所要求的属性,就可以正确运行。
如果类型 B 可以赋值给类型 A,TypeScript 就认为 B 是 A 的子类型(subtyping),A 是 B 的父类型。子类型满足父类型的所有结构特征,同时还具有自己的特征。凡是可以使用父类型的地方,都可以使用子类型,即子类型兼容父类型。
严格字面量检查
如果对象使用字面量表示,会触发 TypeScript 的严格字面量检查(strict object literal checking)。如果字面量的结构跟类型定义的不一样(比如多出了未定义的属性),就会报错。
1 | const point:{ |
TypeScript 对字面量进行严格检查的目的,主要是防止拼写错误。一般来说,字面量大多数来自手写,容易出现拼写错误,或者误用 API。
由于严格字面量检查,字面量对象传入函数必须很小心,不能有多余的属性。
1
2
3
4
5
6
7
8
9 interface Point {
x: number;
y: number;
}
function computeDistance(point: Point) { /*...*/ }
computeDistance({ x: 1, y: 2, z: 3 }); // 报错
computeDistance({x: 1, y: 2}); // 正确规避严格字面量检查有以下3种方式:
使用中间变量
1
2
3
4
5
6
7
8
9
10
11 type Options = {
title:string
};
let myOptions = {
title: '我的网页',
darkmode: true,
};
// 根据结构类型原则,任何对象只要有title属性,都认为符合Options类型。
const obj:Options = myOptions;增加索引属性
1
2
3
4
5
6 let x: {
foo: number,
[x: string]: any
};
x = { foo: 1, baz: 2 }; // Ok如果允许字面量有多余属性,可以像上面这样在类型里面定义一个通用属性。
使用类型断言
1
2
3
4 const obj:Options = {
title: '我的网页',
darkmode: true,
} as Options注意上面需要自己确保写的没错,因为类型断言是主动告诉编译器变量的类型,如果不对的话就可能会出现运行时报错
关闭多余属性检查
编译器选项
suppressExcessPropertyErrors
,可以关闭多余属性检查。下面是它在 tsconfig.json 文件里面的写法。
1
2
3
4
5 {
"compilerOptions": {
"suppressExcessPropertyErrors": true
}
}
最小可选属性规则
根据“结构类型”原则,如果一个对象的所有属性都是可选的,那么其他对象跟它都是结构类似的。
为了避免这种情况,TypeScript 2.4 引入了一个“最小可选属性规则”,也称为“弱类型检测”(weak type detection)。
1 | type Options = { |
上面示例中,对象opts
与类型Options
没有共同属性,赋值给该类型的变量就会报错。
报错原因是,如果某个类型的所有属性都是可选的,那么该类型的对象必须至少存在一个可选属性,不能所有可选属性都不存在。这就叫做“最小可选属性规则”。
如果想规避这条规则,要么在类型里面增加一条索引属性([propName: string]: someType
),要么使用类型断言(opts as Options
)。
空对象
空对象是 TypeScript 的一种特殊值,也是一种特殊类型。
1 | const obj = {}; |
原因是这时 TypeScript 会推断变量obj
的类型为空对象,实际执行的是下面的代码。
1 | const obj:{} = {}; |
空对象没有自定义属性,所以对自定义属性赋值就会报错。空对象只能使用继承的属性,即继承自原型对象
Object.prototype
的属性。
1 obj.toString() // 正确由于 Typescript 中不能动态添加属性,所以对象不能分布生成,必须生成时一次性声明所有属性。
1
2
3
4
5
6
7
8
9
10 // 错误
const pt = {};
pt.x = 3;
pt.y = 4;
// 正确
const pt = {
x: 3,
y: 4
};如果确实需要分步声明,一个比较好的方法是,使用扩展运算符(
...
)合成一个新对象。
1
2
3
4
5
6
7 const pt0 = {};
const pt1 = { x: 3 };
const pt2 = { y: 4 };
const pt = {
...pt0, ...pt1, ...pt2
};空对象作为类型,其实是
Object
类型的简写形式。
1
2
3
4
5
6
7
8 let d:{};
// 等同于
// let d:Object;
d = {};
d = { x: 1 };
d = 'hello';
d = 2;上面示例中,各种类型的值(除了
null
和undefined
)都可以赋值给空对象类型,跟Object
类型的行为是一样的。因为
Object
可以接受各种类型的值,而空对象是Object
类型的简写,所以它不会有严格字面量检查,赋值时总是允许多余的属性,只是不能读取这些属性。
1
2
3 interface Empty { }
const b:Empty = {myProp: 1, anotherProp: 2}; // 正确
b.myProp // 报错如果想强制使用没有任何属性的对象,可以采用下面的写法。
1
2
3
4
5
6 interface WithoutProperties {
[key: string]: never;
}
// 报错
const a:WithoutProperties = { prop: 1 };
interface 接口
简介
interface 是对象的模板,可以看作是一种类型约定,中文译为“接口”。使用了某个模板的对象,就拥有了指定的类型结构。
1 | interface Person { |
interface 可以表示对象的各种语法,它的成员有5种形式。
- 对象属性
- 对象的属性索引
- 对象方法
- 函数
- 构造函数
对象属性
与对象类型声明类似,有可选属性,只读属性
1
2
3
4
5interface Point {
x: number;
readonly y: number; // 只读
z?: number; // 可选
}对象的属性索引
与对象的属性索引一样
1
2
3interface A {
[prop: string]: number;
}属性索引共有
string
、number
和symbol
三种类型。一个接口中,最多只能定义一个类型的索引。该类型索引会约束所有该类型的属性。
1
2
3
4
5interface MyObj {
[prop: string]: number;
a: boolean; // 编译错误,a的属性值是布尔,与字符串属性索引定义的不一致
}属性的数值索引,其实是指定数组的类型。
1
2
3
4
5interface A {
[prop: number]: string;
}
const obj:A = ['a', 'b', 'c'];如果一个 interface 同时定义了字符串索引和数值索引,那么数值索引必须服从于字符串索引。
因为在 JavaScript 中,数值属性名最终是自动转换成字符串属性名。
1
2
3
4
5
6
7
8
9nterface A {
[prop: string]: number;
[prop: number]: string; // 报错
}
interface B {
[prop: string]: number;
[prop: number]: number; // 正确
}
对象的方法
1
2
3
4
5
6
7
8
9
10
11
12
13
14// 写法一
interface A {
f(x: boolean): string;
}
// 写法二
interface B {
f: (x: boolean) => string;
}
// 写法三
interface C {
f: { (x: boolean): string };
}属性名可以采用表达式
1
2
3
4
5
6
7
8
9const f = 'f';
interface A {
[f](x: boolean): string;
}
const a:A = {
f: x => String(x)
}类型方法可以重载
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21interface A {
f(): number;
f(x: boolean): boolean;
f(x: string, y: string): string;
}
function MyFunc(): number;
function MyFunc(x: boolean): boolean;
function MyFunc(x: string, y: string): string;
function MyFunc(
x?:boolean|string, y?:string
):number|boolean|string {
if (x === undefined && y === undefined) return 1;
if (typeof x === 'boolean' && y === undefined) return true;
if (typeof x === 'string' && typeof y === 'string') return 'hello';
throw new Error('wrong parameters');
}
const a:A = {
f: MyFunc
}上面示例中,接口
A
的方法f()
有函数重载,需要额外定义一个函数MyFunc()
实现这个重载,然后部署接口A
的对象a
的属性f
等于函数MyFunc()
就可以了。
函数
interface 也可以用来声明独立的函数。
1
2
3
4
5interface Add {
(x:number, y:number): number;
}
const myAdd:Add = (x,y) => x + y;构造函数
interface 内部可以使用
new
关键字,表示构造函数。1
2
3
4
5
6
7
8
9
10
11
12
13
14
15class Animal {
numLegs:number = 4;
}
// 构造函数类型声明
// type AnimalConstructor = new () => Animal; // 也可以用type
interface AnimalConstructor {
new (): Animal;
}
function create(c:AnimalConstructor):Animal {
return new c();
}
const a = create(Animal);
继承
interface 可以继承其他类型,主要有下面几种情况。
继承 interface
interface 可以使用
extends
关键字,继承其他 interface。1
2
3
4
5
6
7interface Shape {
name: string;
}
interface Circle extends Shape {
radius: number;
}上面示例中,
Circle
继承了Shape
,所以Circle
其实有两个属性name
和radius
。这时,Circle
是子接口,Shape
是父接口。interface 允许多重继承。
多重接口继承,实际上相当于多个父接口的合并。
1
2
3
4
5
6
7
8
9
10
11interface Style {
color: string;
}
interface Shape {
name: string;
}
interface Circle extends Style, Shape {
radius: number;
}如果子接口与父接口存在同名属性,那么子接口的属性会覆盖父接口的属性。
注意,子接口与父接口的同名属性必须是类型兼容的,不能有冲突,否则会报错。
1
2
3
4
5
6
7interface Foo {
id: string;
}
interface Bar extends Foo {
id: number; // 报错
}如果多个父接口存在同名属性,那么这些同名属性不能有类型冲突,否则会报错。
1
2
3
4
5
6
7
8
9
10
11
12interface Foo {
id: string;
}
interface Bar {
id: number;
}
// 报错
interface Baz extends Foo, Bar {
type: string;
}
继承 type
interface 可以继承
type
命令定义的对象类型。1
2
3
4
5
6
7
8
9type Country = {
name: string;
capital: string;
}
// 和 interface 接口一般,会合并 type 对象属性
interface CountryWithPop extends Country {
population: number;
}注意,如果
type
命令定义的类型不是对象,interface 就无法继承。继承 class
interface 还可以继承 class,即继承该类的所有成员。
1
2
3
4
5
6
7
8
9
10
11class A {
x:string = '';
y():boolean {
return true;
}
}
interface B extends A {
z: number
}上面示例中,
B
继承了A
,因此B
就具有属性x
、y()
和z
。实现B
接口的对象就需要实现这些属性。某些类拥有私有成员和保护成员,interface 可以继承这样的类,但是意义不大。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16class A {
private x: string = '';
protected y: string = '';
}
interface B extends A {
z: number
}
// 报错
const b:B = { /* ... */ }
// 报错
class C implements B {
// ...
}上面示例中,
A
有私有成员和保护成员,B
继承了A
,但无法用于对象,因为对象不能实现这些成员。这导致B
只能用于其他 class,而这时其他 class 与A
之间不构成父类和子类的关系,使得x
与y
无法部署。
合并
多个同名接口会合并成一个接口。
1 | interface Box { |
上面示例中,两个Box
接口会合并成一个接口,同时有height
、width
和length
三个属性。
这样的设计主要是为了兼容 JavaScript 的行为。JavaScript 开发者常常对全局对象或者外部库,添加自己的属性和方法。那么,只要使用 interface 给出这些自定义属性和方法的类型,就能自动跟原始的 interface 合并,使得扩展外部类型非常方便。
举例来说,Web 网页开发经常会对window
对象和document
对象添加自定义属性,但是 TypeScript 会报错,因为原始定义没有这些属性。解决方法就是把自定义属性写成 interface,合并进原始定义。
1 | interface Document { |
同名接口合并时,同一个属性如果有多个类型声明,彼此不能有类型冲突。
1
2
3
4
5
6
7 interface A {
a: number;
}
interface A {
a: string; // 报错
}同名接口合并时,如果同名方法有不同的类型声明,那么会发生函数重载。而且,后面的定义比前面的定义具有更高的优先级。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20 interface Cloner {
clone(animal: Animal): Animal;
}
interface Cloner {
clone(animal: Sheep): Sheep;
}
interface Cloner {
clone(animal: Dog): Dog;
clone(animal: Cat): Cat;
}
// 等同于
interface Cloner {
clone(animal: Dog): Dog;
clone(animal: Cat): Cat;
clone(animal: Sheep): Sheep;
clone(animal: Animal): Animal;
}这个规则有一个例外。同名方法之中,如果有一个参数是字面量类型,字面量类型有更高的优先级。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15 interface A {
f(x:string): void;
f(x:'foo'): boolean;
}
interface A {
f(x:any): void;
}
// 等同于
interface A {
f(x:'foo'): boolean;
f(x:any): void;
f(x:string): void;
}如果两个 interface 组成的联合类型存在同名属性,那么该属性的类型也是联合类型。
1
2
3
4
5
6
7
8
9
10
11 interface Circle {
area: bigint;
}
interface Rectangle {
area: number;
}
declare const s: Circle | Rectangle;
s.area; // bigint | number本例中的
declare
命令表示变量s
的具体定义,由其他脚本文件给出,详见《declare 命令》一章。
与type的异同
interface
命令与type
命令作用类似,都可以表示对象类型。
很多对象类型既可以用 interface 表示,也可以用 type 表示。而且,两者往往可以换用,几乎所有的 interface 命令都可以改写为 type 命令。
它们的相似之处,首先表现在都能为对象类型起名。
1 | type Country = { |
上面示例是type
命令和interface
命令,分别定义同一个类型。
class
命令也有类似作用,通过定义一个类,同时定义一个对象类型。但是,它会创造一个值,编译后依然存在。如果只是单纯想要一个类型,应该使用type
或interface
。
interface 与 type 的区别有下面几点。
type
能够表示非对象类型,而interface
只能表示对象类型(包括数组、函数等)。interface
可以继承其他类型,type
不支持继承。继承的主要作用是添加属性,
type
定义的对象类型如果想要添加属性,只能使用&
运算符,重新定义一个类型。1
2
3
4
5
6
7type Animal = {
name: string
}
type Bear = Animal & {
honey: boolean // 新增的属性
}作为比较,
interface
添加属性,采用的是继承的写法。1
2
3
4
5
6
7interface Animal {
name: string
}
interface Bear extends Animal {
honey: boolean
}继承时,type 和 interface 是可以换用的。interface 可以继承 type,type 也可以”继承” interface。
1
2
3
4
5
6
7
8
9
10
11type Foo1 = { x: number; };
interface Bar extends Foo1 {
y: number;
}
interface Foo2 {
x: number;
}
type Bar = Foo2 & { y: number; };同名
interface
会自动合并,同名type
则会报错。interface 是开放的,可以添加属性,type 是封闭的,不能添加属性,只能定义新的 type。
1
2
3
4
5
6
7
8
9type A = { foo:number }; // 报错
type A = { bar:number }; // 报错
interface B { foo:number };
interface B { bar:number };
const obj:B = {
foo: 1,
bar: 1
};interface
不能包含属性映射(mapping),type
可以,详见《映射》一章。1
2
3
4
5
6
7
8
9
10
11
12
13
14interface Point {
x: number;
y: number;
}
// 正确
type PointCopy1 = {
[Key in keyof Point]: Point[Key];
};
// 报错
interface PointCopy2 {
[Key in keyof Point]: Point[Key];
};this
关键字只能用于interface
。1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17// 报错
type Foo = {
add(num:number): this;
};
// 正确
interface Foo {
add(num:number): this;
};
class Calculator implements Foo {
result = 0;
add(num:number) {
this.result += num;
return this;
}
}type 可以扩展原始数据类型,interface 不行。(
@todo: 教程虽然如此写到,但是type的这个有意义吗?找不到应用实例可以满足这个类型声明
)1
2
3
4
5
6
7
8
9// 正确
type MyStr = string & {
type: 'new'
};
// 报错
interface MyStr extends string {
type: 'new'
}interface
无法表达某些复杂类型(比如交叉类型和联合类型),但是type
可以。1
2
3
4
5
6
7type A = { /* ... */ };
type B = { /* ... */ };
type AorB = A | B;
type AorBwithName = AorB & {
name: string
};
综上所述,如果有复杂的类型运算,那么没有其他选择只能使用type
;一般情况下,interface
灵活性比较高,便于扩充类型或自动合并,建议优先使用。
类
简介
类(class)是面向对象编程的基本构件,封装了属性和方法。
下面关于类的示例都关闭了 “
strictPropertyInitialization
” 配置,所以很多都是没有赋初值
属性的类型
类的属性可以在顶层声明,也可以在构造方法内部声明。
对于顶层声明的属性,可以在声明时同时给出类型。
1 | class Point { |
PS: TypeScript 有一个配置项
strictPropertyInitialization
,只要打开(默认是打开的),就会检查属性是否设置了初值,如果没有就报错。
readonly 修饰符
属性名前面加上 readonly 修饰符,就表示该属性是只读的。实例对象不能修改这个属性。
1 | class A { |
上面示例中,构造方法修改只读属性的值也是可以的。或者说,如果两个地方都设置了只读属性的值,以构造方法为准。在其他方法修改只读属性都会报错。
方法的类型
类的方法就是普通函数,类型声明方式与函数一致,可以使用参数默认值,以及函数重载。
1 | class Point { |
注意:构造方法不能声明返回值类型
存取器方法
存取器(accessor)是特殊的类方法,包括取值器(getter)和存值器(setter)两种方法。
它们用于读写某个属性,取值器用来读取属性,存值器用来写入属性。
1 | class C { |
TypeScript 对存取器有以下规则
如果某个属性只有
get
方法,没有set
方法,那么该属性自动成为只读属性。TypeScript 5.1 版之前,
set
方法的参数类型,必须兼容get
方法的返回值类型,否则报错。TypeScript 5.1 版做出了改变,现在两者可以不兼容。
1
2
3
4
5
6
7
8
9 class C {
_name = '';
get name():string {
return this._name;
}
set name(value:number|string) { // TypeScript 5.1 版之前
this._name = String(value);
}
}
get
方法与set
方法的可访问性必须一致,要么都为公开方法,要么都为私有方法。
属性索引
类允许定义属性索引,相当于对象的属性索引
1 | class MyClass { |
PS: 由于类的方法是一种特殊属性(属性值为函数的属性),所以属性索引的类型定义也涵盖了方法。如果一个对象同时定义了属性索引和方法,那么前者必须包含后者的类型。唯一例外的情况是属性存取器。
1
2
3
4
5
6
7 class MyClass {
[s:string]: boolean;
get isInstance() {
return true;
}
}上面示例中,属性
inInstance
的读取器虽然是一个函数方法,但是视同属性,所以属性索引虽然没有涉及方法类型,但是不会报错。
类的interface接口
implements 关键字
interface 接口或 type 别名,可以用对象的形式,为 class 指定一组检查条件(类似类的模板)。然后,类使用 implements 关键字,表示当前类满足这些外部类型条件的限制。
1 | interface Country { |
interface 只是指定检查条件,如果不满足这些条件就会报错。它并不能代替 class 自身的类型声明。
1
2
3
4
5
6
7
8
9 interface A {
get(name:string): boolean;
}
class B implements A {
get(s) { // s 的类型是 any
return true;
}
}与1同理,可选属性在接口中声明之后也是要在类中声明的
implements
关键字后面,不仅可以是接口,也可以是另一个类。这时,后面的类将被当作接口。
1
2
3
4
5
6
7
8
9 class Car {
id:number = 1;
move():void {};
}
class MyCar implements Car { // 需要实现Car里面的每一个属性和方法
id = 2; // 不可省略
move():void {}; // 不可省略
}interface 描述的是类的对外接口,也就是实例的公开属性和公开方法,不能定义私有的属性和方法。这是因为 TypeScript 设计者认为,私有属性是类的内部实现,接口作为模板,不应该涉及类的内部代码写法
1
2
3 interface Foo {
private member:{}; // 报错
}
实现多个接口
类可以实现多个接口(其实是接受多重限制),每个接口之间使用逗号分隔。
1 | class Car implements MotorVehicle, Flyable, Swimmable { |
但是,同时实现多个接口并不是一个好的写法,容易使得代码难以管理,可以使用两种方法替代。
类的继承
1
2
3
4
5 class Car implements MotorVehicle {
}
class SecretCar extends Car implements Flyable, Swimmable {
}接口的继承
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17 interface MotorVehicle {
// ...
}
interface Flyable {
// ...
}
interface Swimmable {
// ...
}
interface SuperCar extends MotoVehicle,Flyable, Swimmable {
// ...
}
class SecretCar implements SuperCar {
// ...
}
类与接口的合并
TypeScript 不允许两个同名的类,但是如果一个类和一个接口同名,那么接口会被合并进类。
1 | class A { |
Class 类型
实例类型
TypeScript 的类本身就是一种类型,但是它代表该类的实例类型,而不是 class 的自身类型。
1 | class Color { |
作为类型使用时,类名只能表示实例的类型,不能表示类的自身类型。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21 class Point {
x: number;
y: number;
constructor(x: number, y: number) {
this.x = x;
this.y = y;
}
}
function createPoint(
// PointClass:Point, // 这里的写法就是错误的,因为Point描述的是实例类型,而不是 Class 的自身类型。
PointClass: typeof Point, // 使用 typeof 获取 Point 类的构造函数类型
x: number,
y: number
) {
return new PointClass(x, y);
}
const myPoint = createPoint(Point, 10, 20);
console.log(myPoint); // 输出 Point { x: 10, y: 20 }
- 对于引用实例对象的变量来说,既可以声明类型为 Class,也可以声明类型为 Interface,因为两者都代表实例对象的类型。
1
2
3
4
5
6
7
8
9
10 interface MotorVehicle {
}
class Car implements MotorVehicle {
}
// 写法一
const c1:Car = new Car();
// 写法二
const c2:MotorVehicle = new Car();上面示例中,变量的类型可以写成类
Car
,也可以写成接口MotorVehicle
。它们的区别是,如果类Car
有接口MotoVehicle
没有的属性和方法,那么只有变量c1
可以调用这些属性和方法。由于类名作为类型使用,实际上代表一个对象,因此可以把类看作为对象类型起名。事实上,TypeScript 有三种方法可以为对象类型起名:type、interface 和 class。
类的自身类型
要获得一个类的自身类型,有2种方法。
typeof 运算符(最简便)
1
2
3
4
5
6
7function createPoint(
PointClass:typeof Point,
x:number,
y:number
):Point {
return new PointClass(x, y);
}构造函数 形式
JavaScript 语言中,类只是构造函数的一种语法糖,本质上是构造函数的另一种写法。所以,类的自身类型可以写成构造函数的形式。
1
2
3
4
5
6
7function createPoint(
PointClass: new (x:number, y:number) => Point,
x: number,
y: number
):Point {
return new PointClass(x, y);
}构造函数形式的其他写法可以参考《函数类型》一节
结构类型原则
Class 也遵循“结构类型原则”。一个对象只要满足 Class 的实例结构,就跟该 Class 属于同一个类型。
1 | class Foo { |
如果两个类的实例结构相同,那么这两个类就是兼容的,可以用在对方的使用场合。
1
2
3
4
5
6
7
8
9
10 class Person {
name: string;
}
class Customer {
name: string;
}
// 正确
const cust:Customer = new Person();只要 A 类具有 B 类的结构,哪怕还有额外的属性和方法,TypeScript 也认为 A 兼容 B 的类型。
1
2
3
4
5
6
7
8
9
10
11
12
13 class Person {
name: string;
age: number;
}
class Customer {
name: string;
}
// 正确
const cust:Customer = new Person();
// 报错
const pers:Person = new Customer();不仅是类,如果某个对象跟某个 class 的实例结构相同,TypeScript 也认为两者的类型相同。
1
2
3
4
5
6 class Person {
name: string;
}
const obj = { name: 'John' };
const p:Person = obj; // 正确凡是类型为空类的地方,所有类(包括对象)都可以使用。(空类不包含任何成员,任何其他类都可以看作与空类结构相同)
1
2
3
4
5
6
7
8
9 class Empty {}
function fn(x:Empty) {
// ...
}
fn({});
fn(window);
fn(fn);上面示例中,函数
fn()
的参数是一个空类,这意味着任何对象都可以用作fn()
的参数。确定两个类的兼容关系时,只检查实例成员,不考虑静态成员和构造方法。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15 class Point {
x: number;
y: number;
static t: number;
constructor(x:number) {}
}
class Position {
x: number;
y: number;
z: number;
constructor(x:string) {}
}
const point:Point = new Position(''); // 正确如果类中存在私有成员(private)或保护成员(protected),那么确定兼容关系时,TypeScript 要求私有成员和保护成员来自同一个类,这意味着两个类需要存在继承关系。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20 // 情况一
class A {
private name = 'a';
}
class B extends A {
}
const a:A = new B();
// 情况二
class A {
protected name = 'a';
}
class B extends A {
protected name = 'b';
}
const a:A = new B();上面示例中,
A
和B
都有私有成员(或保护成员)name
,这时只有在B
继承A
的情况下(class B extends A
),B
才兼容A
。
类的继承
类(这里又称“子类”)可以使用 extends 关键字继承另一个类(这里又称“基类”)的所有属性和方法。
1 | class A { |
子类可以覆盖基类的同名方法。
1
2
3
4
5
6
7
8
9 class B extends A {
greet(name?: string) { // 注意不能与基类的类型定义相冲突,比如 greet(name:string) 就会报错了
if (name === undefined) {
super.greet();
} else {
console.log(`Hello, ${name}`);
}
}
}子类可以更改基类的 保护成员(
protected
修饰符)为 公开(public
修饰符),但是不能改用私有成员(private
修饰符)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16 class A {
protected x: string = '';
protected y: string = '';
protected z: string = '';
}
class B extends A {
// 正确
public x:string = '';
// 正确
protected y:string = '';
// 报错
private z: string = '';
}
extends
关键字后面不一定是类名,可以是一个表达式,只要它的类型是构造函数就可以了。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35 // 例一
class MyArray extends Array<number> {}
// 例二
class MyError extends Error {}
// 例三
class A {
greeting() {
return 'Hello from A';
}
}
class B {
greeting() {
return 'Hello from B';
}
}
interface Greeter {
greeting(): string;
}
interface GreeterConstructor {
new (): Greeter;
}
function getGreeterBase():GreeterConstructor {
return Math.random() >= 0.5 ? A : B;
}
class Test extends getGreeterBase() {
sayHello() {
console.log(this.greeting());
}
}对于那些只设置了类型、没有初值的顶层属性,有一个细节需要注意。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31 interface Animal {
animalStuff: any;
}
interface Dog extends Animal {
dogStuff: any;
}
class AnimalHouse {
resident: Animal;
constructor(animal:Animal) {
this.resident = animal;
}
}
class DogHouse extends AnimalHouse {
resident: Dog;
constructor(dog:Dog) {
super(dog);
}
}
const dog = {
animalStuff: 'animal',
dogStuff: 'dog'
};
const dogHouse = new DogHouse(dog);
console.log(dogHouse.resident)上面示例中,类
DogHouse
的顶层成员resident
只设置了类型(Dog
),没有设置初值。这段代码在不同的编译设置下,编译结果不一样。如果编译设置的
target
设成大于等于ES2022
,或者useDefineForClassFields
设成true
,那么DogHouse
实例的属性resident
输出的是undefined
,而不是预料的dog
。 原因在于 ES2022 标准的 Class Fields 部分,与早期的 TypeScript 实现不一致,导致子类的那些只设置类型、没有设置初值的顶层成员在基类中被赋值后,会在子类被重置为undefined
,详细的解释参见《tsconfig.json》一章,以及官方 3.7 版本的发布说明。解决方法就是使用
declare
命令,去声明顶层成员的类型,告诉 TypeScript 这些成员的赋值由基类实现。
1
2
3
4
5
6
7 class DogHouse extends AnimalHouse {
declare resident: Dog;
constructor(dog:Dog) {
super(dog);
}
}
可访问性修饰符
类的内部成员的外部可访问性,由三个可访问性修饰符(access modifiers)控制:public
、private
和protected
。
这三个修饰符的位置,都写在属性或方法的最前面。
public
public
修饰符表示这是公开成员,外部可以自由访问。
1 | class Greeter { |
public
修饰符是默认修饰符,如果省略不写,实际上就带有该修饰符。因此,类的属性和方法默认都是外部可访问的。正常情况下,除非为了醒目和代码可读性,
public
都是省略不写的。
private
private
修饰符表示私有成员,只能用在当前类的内部,类的实例和子类都不能使用该成员。
1 | class A { |
严格地说,
private
定义的私有成员,并不是真正意义的私有成员。一方面,编译成 JavaScript 后,private
关键字就被剥离了,这时外部访问该成员就不会报错。另一方面,由于前一个原因,TypeScript 对于访问private
成员没有严格禁止,使用方括号写法([]
)或者in
运算符,实例对象就能访问该成员。
1
2
3 const a = new A();
console.log(a['x']); // 0
console.log('x' in a)); // trueES2022 引入了自己的私有成员写法
#propName
,如果target
大于或者等于es2022
,建议不使用private
,改用 ES2022 的写法,获得真正意义的私有成员。
1
2
3
4
5
6 class A {
#x = 1;
}
const a = new A();
a['x'] // 报错构造方法也可以是私有的,这就直接防止了使用
new
命令生成实例对象,只能在类的内部创建实例对象。
1
2
3
4
5
6
7
8
9
10
11
12
13
14 class Singleton {
private static instance?: Singleton;
private constructor() {}
static getInstance() {
if (!Singleton.instance) {
Singleton.instance = new Singleton();
}
return Singleton.instance;
}
}
const s = Singleton.getInstance();上面示例使用私有构造方法,实现了单例模式。想要获得 Singleton 的实例,不能使用
new
命令,只能使用getInstance()
方法。
protected
protected
修饰符表示该成员是保护成员,只能在类的内部使用该成员,实例无法使用该成员,但是子类内部可以使用。
1 | class A { |
实例属性的简写形式
实际开发中,很多实例属性的值,是通过构造方法传入的。
1 | class Point { |
上面实例中,属性x
和y
的值是通过构造方法的参数传入的。
这样的写法等于对同一个属性要声明两次类型,一次在类的头部,另一次在构造方法的参数里面。这有些累赘,TypeScript 就提供了一种简写形式。
1 | class Point { |
上面示例中,构造方法的参数x
前面有public
修饰符,这时 TypeScript 就会自动声明一个公开属性x
,不必在构造方法里面写任何代码,同时还会设置x
的值为构造方法的参数值。注意,这里的public
不能省略。
除了
public
修饰符,构造方法的参数名只要有private
、protected
、readonly
修饰符,都会自动声明对应修饰符的实例属性。
readonly
还可以与其他三个可访问性修饰符,一起使用。
1
2
3
4
5
6
7 class A {
constructor(
public readonly x:number,
protected readonly y:number,
private readonly z:number
) {}
}
静态成员
类的内部可以使用static
关键字,定义静态成员。
静态成员是只能通过类本身使用的成员,不能通过实例对象使用。
1 | class MyClass { |
上面示例中,x
是静态属性,printX()
是静态方法。它们都必须通过MyClass
获取,而不能通过实例对象调用。
static
关键字前面可以使用 public、private、protected 修饰符,分别对应其应用范围。
范类型
类也可以写成泛型,使用类型参数。关于泛型的详细介绍,请看《泛型》一章。
1 | class Box<Type> { |
上面示例中,类Box
有类型参数Type
,因此属于泛型类。新建实例时,变量的类型声明需要带有类型参数的值,不过本例等号左边的Box<string>
可以省略不写,因为可以从等号右边推断得到。
注意,静态成员不能使用泛型的类型参数。
1
2
3 class Box<Type> {
static defaultContents: Type; // 报错
}上面示例中,静态属性
defaultContents
的类型写成类型参数Type
会报错。因为这意味着调用时必须给出类型参数(即写成Box<string>.defaultContents
),并且类型参数发生变化,这个属性也会跟着变,这并不是好的做法。
抽象类、抽象成员
TypeScript 允许在类的定义前面,加上关键字abstract
,表示该类不能被实例化,只能当作其他类的模板。这种类就叫做“抽象类”(abstract class)。
1 | abstract class A { |
抽象类只能当作基类使用,用来在它的基础上定义子类。
1
2
3
4
5
6
7
8
9
10
11
12 abstract class A {
id = 1;
}
class B extends A {
amount = 100;
}
const b = new B();
b.id // 1
b.amount // 100抽象类的子类也可以是抽象类,也就是说,抽象类可以继承其他抽象类。
1
2
3
4
5
6
7
8
9
10
11
12 abstract class A {
id = 1;
}
class B extends A {
amount = 100;
}
const b = new B();
b.id // 1
b.amount // 100抽象类的内部可以有已经实现好的属性和方法,也可以有还未实现的属性和方法。后者就叫做“抽象成员”(abstract member),即属性名和方法名有
abstract
关键字,表示该方法需要子类实现。如果子类没有实现抽象成员,就会报错。
1
2
3
4
5
6
7
8
9
10
11
12 abstract class A {
abstract foo:string;
abstract execute():string;
bar:string = '';
}
class B extends A {
foo = 'b';
execute() {
return `B executed`;
}
}这里有几个注意点。
(1)抽象成员只能存在于抽象类,不能存在于普通类。
(2)抽象成员不能有具体实现的代码。也就是说,已经实现好的成员前面不能加
abstract
关键字。(3)抽象成员前也不能有
private
修饰符,否则无法在子类中实现该成员。(4)一个子类最多只能继承一个抽象类。
总之,抽象类的作用是,确保各种相关的子类都拥有跟基类相同的接口,可以看作是模板。其中的抽象成员都是必须由子类实现的成员,非抽象成员则表示基类已经实现的、由所有子类共享的成员。
this问题
类的方法经常用到this
关键字,它表示该方法当前所在的对象。
1 | class A { |
上面示例中,变量a
和b
的getName()
是同一个方法,但是执行结果不一样,原因就是它们内部的this
指向不一样的对象。如果getName()
在变量a
上运行,this
指向a
;如果在b
上运行,this
指向b
。
有些场合需要给出this
类型,但是 JavaScript 函数通常不带有this
参数,这时 TypeScript 允许函数增加一个名为this
的参数,放在参数列表的第一位,用来描述函数内部的this
关键字的类型。
1 | class A { |
编译时,TypeScript 一旦发现函数的第一个参数名为
this
,则会去除这个参数,即编译结果不会带有该参数。
this
参数的类型可以声明为各种对象。
1
2
3
4
5
6
7
8 function foo(
this: { name: string }
) {
this.name = 'Jack';
this.name = 0; // 报错
}
foo.call({ name: 123 }); // 报错TypeScript 提供了一个
noImplicitThis
编译选项。如果打开了这个设置项,如果this
的值推断为any
类型,就会报错。
1
2
3
4
5
6
7
8
9
10
11
12
13
14 // noImplicitThis 打开
class Rectangle {
constructor(
public width:number,
public height:number
) {}
getAreaFunction() {
return function () {
return this.width * this.height; // 报错,因为这里的 this 跟Rectangle这个类没关系,它的类型推断为any
};
}
}在类的内部,
this
本身也可以当作类型使用,表示当前类的实例对象。
1
2
3
4
5
6
7
8
9 class Box {
static a:this; // 报错,this不允许应用于静态成员,原因是this类型表示实例对象,但是静态成员拿不到实例对象。
contents:string = '';
set(value:string):this {
this.contents = value;
return this;
}
}有些方法返回一个布尔值,表示当前的
this
是否属于某种类型。这时,这些方法的返回值类型可以写成this is Type
的形式。
1
2
3
4
5
6
7
8
9
10
11 class FileSystemObject {
isFile(): this is FileRep {
return this instanceof FileRep;
}
isDirectory(): this is Directory {
return this instanceof Directory;
}
// ...
}
is
运算符的介绍详见《类型断言》一章。
泛型
泛型可以理解成一段类型逻辑,需要类型参数来表达。有了类型参数以后,可以在输入类型与输出类型之间,建立一一对应关系。
简介
有些时候,函数返回值的类型与参数类型是相关的。
1 | function getFirst(arr) { |
上面示例中,函数getFirst()
总是返回参数数组的第一个成员。参数数组是什么类型,返回值就是什么类型。
这个函数的类型声明只能写成下面这样。
1 | function f(arr:any[]):any { |
上面的类型声明,就反映不出参数与返回值之间的类型关系。
为了解决这个问题,TypeScript 就引入了“泛型”(generics)。泛型的特点就是带有“类型参数”(type parameter)。
1 | function getFirst<T>(arr:T[]):T { |
上面示例中,函数getFirst()
的函数名后面尖括号的部分<T>
,就是类型参数,参数要放在一对尖括号(<>
)里面。本例只有一个类型参数T
,可以将其理解为类型声明需要的变量,需要在调用时传入具体的参数类型。
上例的函数getFirst()
的参数类型是T[]
,返回值类型是T
,就清楚地表示了两者之间的关系。比如,输入的参数类型是number[]
,那么 T 的值就是number
,因此返回值类型也是number
。
函数调用时,需要提供类型参数。
1 | getFirst<number>([1, 2, 3]) |
上面示例中,调用函数getFirst()
时,需要在函数名后面使用尖括号,给出类型参数T
的值,本例是<number>
。
不过为了方便,函数调用时,往往省略不写类型参数的值,让 TypeScript 自己推断。
1 | getFirst([1, 2, 3]) |
上面示例中,TypeScript 会从实际参数[1, 2, 3]
,推断出类型参数 T 的值为number
。
有些复杂的使用场景,TypeScript 可能推断不出类型参数的值,这时就必须显式给出了。
1 | function comb<T>(arr1:T[], arr2:T[]):T[] { |
上面示例中,两个参数arr1
、arr2
和返回值都是同一个类型。如果不给出类型参数的值,下面的调用会报错。
1 | comb([1, 2], ['a', 'b']) // 报错 |
上面示例会报错,TypeScript 认为两个参数不是同一个类型。但是,如果类型参数是一个联合类型,就不会报错。
1 | comb<number|string>([1, 2], ['a', 'b']) // 正确 |
上面示例中,类型参数是一个联合类型,使得两个参数都符合类型参数,就不报错了。这种情况下,类型参数是不能省略不写的。
类型参数的名字,可以随便取,但是必须为合法的标识符。习惯上,类型参数的第一个字符往往采用大写字母。一般会使用T
(type 的第一个字母)作为类型参数的名字。如果有多个类型参数,则使用 T 后面的 U、V 等字母命名,各个参数之间使用逗号(“,”)分隔。
扩展,一般按照java泛型的键入参数命名约定,见:https://iowiki.com/java_generics/java_generics_type_parameters.html
- E - Element,主要由Java Collections框架使用。
- K - Key,主要用于表示地图键的参数类型。
- V - Value,主要用于表示地图值的参数类型。
- N - 数字,主要用于表示数字。
- T - Type,主要用于表示第一个泛型类型参数。
- S - Type,主要用于表示第二个泛型类型参数。
- U - Type,主要用于表示第三个泛型类型参数。
- V - Type,主要用于表示第四个泛型类型参数。
下面是多个类型参数的例子。
1 | function map<T, U>( |
上面示例将数组的实例方法map()
改写成全局函数,它有两个类型参数T
和U
。含义是,原始数组的类型为T[]
,对该数组的每个成员执行一个处理函数f
,将类型T
转成类型U
,那么就会得到一个类型为U[]
的数组。
总之,泛型可以理解成一段类型逻辑,需要类型参数来表达。有了类型参数以后,可以在输入类型与输出类型之间,建立一一对应关系。
写法
泛型主要用在四个场合:函数、接口、类和别名。
函数
1 | // function关键字定义的泛型函数 |
接口
1 | // 接口对象的泛型 |
类
别名
https://wangdoc.com/typescript/generics
类型参数的默认值
数组泛型的表示
类型参数的约束条件
使用注意点
其他
参考文档:
在线调试:https://ts.nodejs.cn/play
视频教程:https://www.bilibili.com/video/BV1Jv41177bD
官方文档:https://www.tslang.cn/index.html
本文链接: http://www.ionluo.cn/blog/posts/454fcde8.html
版权声明: 本作品采用 知识共享署名-非商业性使用-相同方式共享 4.0 国际许可协议 进行许可。转载请注明出处!