TypeScript 基础

ObjectKaz Lv4

简介

介绍

Typescript 是一个强类型的 JavaScript 超集,支持 ES6 语法,支持面向对象编程的概念,如类、接口、继承、泛型等。Typescript 并不直接在浏览器上运行,需要编译器编译成纯 Javascript 来运行。

使用 TypeScript 的优势

Typescript 对javascript 一个主要的拓充就是类型系统。

类型系统有利于提高代码的质量和可维护性,因为:

  • 类型有利于代码的重构,它有利于编译器在编译时而不是运行时捕获错误。
  • 类型是出色的文档形式之一,函数签名是一个定义,而函数体是具体的实现。
  • 类型有利于更好的 IDE 提示

安装 TypeScript

Node.js 环境下安装:

1
2
npm install -g typescript
npm install -g ts-node

数据类型

变量的定义

变量定义的基本格式:

1
var [变量名]: [类型] = 值;

省略初值时,变量的值为 undefined

1
var [变量名]: [类型];

省略类型时,变量的类型根据变量的初值来确定:

1
var message = "tese"; //message is string

编译器确定变量类型的具体规则,请参考类型推断

省略初值和类型时,变量的类型为 any,初值为 undefined:

1
var message; //message is any

变量的类型一旦确定,则不可更改:

1
2
var message = "tese";
message = 1; //Type 'number' is not assignable to type 'string'

数据类型

数据类型关键字示例描述
数字类型numberlet x: number = 1;用来表示整数和浮点数
大整数类型bigintlet x: bigint = 1;用来表示大整数
字符串类型stringlet x: string = "hello world";使用单引号'或双引号"来表示字符串类型。反引号```来定义多行文本和模板字符串
布尔类型booleanlet x: boolean = false;表示 truefalse 的类型
数组类型let x: number[] = [1,2,3];
let x: any[] = [1,false,3];
let x: Array<number> = [1,2,3];
表示一组同类型的数据
元组类型let x: [number,boolean] = [1,false];表示已知元素数量和类型的一组数据
枚举类型enumenum Sex {Male,Female};let x: Sex = Sex.Male;表示一组数据
对象let x: Object = {a: 1}表示一组键值对
void 类型voidfunction func() : void {}表示函数没有返回值
nullnulllet num: number = null;表示变量不指向任何对象,是其他类型的子类型
undefinedundefinedlet num: number = undefined;表示变量未初始化,是其他类型的子类型
未知类型unknownlet x: unknown;表示无法预知的类型,在包含 typeof=== 的语句中,编译器可以推断出类型。在推断出类型之前无法使用
never 类型neverlet x: never;不可能出现的值,通常表示函数抛出了异常、无限循环,是其他类型的子类型
任意类型anylet x: any = 1;表示变量的类型是任意的,可以赋任何类型的值进去

字面量类型

字符串字面量类型可以约束一个字符串只能是一个值:

1
2
3
type NetworkLoadingState = {
state: "loading";
};

也可以约束其只能是某些字符串中的一个:

1
type EventNames = "click" | "scroll" | "mousemove";

也可以约束其为整数中的某一个:

1
type Month = 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12;

枚举类型

我们可以使用 enum 关键字来定义一个枚举类型,其中 Success 等成为枚举成员:

1
2
3
4
5
enum Status {
Success,
Failed,
Pending,
}

当我们定义完枚举类型后,便可以定义一个枚举类型的变量。使用 枚举类型名.成员名 便可以将一个枚举成员赋值给枚举类型:

1
let networkStatus: Status = Status.Success;

实际上,每一个枚举类型都是有一个特定的整数值的,默认情况下,第一个成员的值是 0,第二个成员是 1,以此类推:

1
2
3
console.log(Status.Success); // 0
console.log(Status.Failed); // 1
console.log(Status.Pending); // 2

我们也可以给他们指定一个值:

1
2
3
4
5
enum Status {
Success = 1,
Failed,
Pending,
}

在这种情况下,第一个成员的值便是 1,第二个成员是 2,以此类推。

如果我们在中间赋值,那么这种规律则从赋值的点开始生效:

1
2
3
4
5
6
7
8
9
enum Status {
Success,
Failed = 2,
Pending,
}

console.log(Status.Success); // 0
console.log(Status.Failed); // 2
console.log(Status.Pending); // 3

需要注意的是,如果枚举成员的值是个变量(或函数),那么它的后面就必须初始化,因为它是无法获得初始值的:

1
2
3
4
5
enum Status {
Success,
Failed = GetSomeValue(),
Pending, //Enum member must have initializer.
}

枚举成员的值也可以是字符串,但是字符串后面的枚举成员也需要赋值:

1
2
3
4
5
enum Status {
Success = "success",
Failed = "failed",
Pending = "pending",
}

常量枚举

常量枚举则是在 enum 前面加上 const

1
2
3
4
5
const enum Tristate {
False,
True,
Unknown,
}

普通枚举类型编译后会生成一个 JavaScript 对象:

1
2
3
4
5
6
var Tristate;
(function (Tristate) {
Tristate[(Tristate["False"] = 0)] = "False";
Tristate[(Tristate["True"] = 1)] = "True";
Tristate[(Tristate["Unknown"] = 2)] = "Unknown";
})(Tristate || (Tristate = {}));

而对于 let test = Tristate.False; 这样的语句,编译前后并不会发生什么变化。

常量枚举编译后不会生成枚举类型的代码。对于 let test = Tristate.False; 这样的语句,编译时直接将 Tristate.False 的值嵌入编译结果,let test = 0;

类型推断

最佳通用类型

当我们定义变量时,若省略类型,则变量的类型会根据变量的初值来确定:

1
var message = "tese"; //message is string

若它的值复杂一点,像这样:

1
let v = [3, 6, 9, null];

为了推断出 v 的类型,编译器想办法兼容上面的所有类型,而上面有 numbernull,最终得到 (number | null) []

如果这几个类型具有子类型关系,那么最终得到的是父类型:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
class Base {
id: number;
}

class User extends Base {
name: string;
age: number;
}

class Article extends Base {
title: string;
content: string;
}

let x = [new Base(), new User(), new Article()]; //Base[]

如果去掉 Base,尽管我们希望它仍能得到 Base,但 Base 未出现在值中,不作为候选的类型:

1
let x = [new User(), new Article()]; // (User | Article)[]

上下文推断

当给一个变量赋值函数类型时,这个变量的类型是已知的,则函数默认采用变量的类型:

1
2
3
let add: (x: number, y: number) => number = function (x, y) {
return x + y;
};

类型别名

类型定义可以为某个类型定义另一个名字:

1
type Name = string;

也可以通过 type 关键字定义一个类型:

1
2
3
type NetworkLoadingState = {
state: "loading";
};

类型断言

类型断言(Type Assertion)相当于其他语言的类型转换。与其他编程语言不同的是,类型断言只发生在编译期,而不是运行期。

类型断言的两种写法:

1
2
表达式 as string;
<类型>表达式; //这是一种比较旧的写法,不推荐使用

类型断言是按照子类型规则来的,也就是说,类型断言只能在有子类型的关系的两个类型中相互断言:

1
2
3
4
let myNumber: string = "2333";
myNumber as never;
myNumber as any;
myNumber as number; // number 和 string 没有子类型的关系

对于联合类型,可以将其断言为其中一个类型:

1
2
let myNumber: string | number;
myNumber as string;

不要忘了,any 和任何一个类型都是兼容的,所以,我们可以通过 any 来进行间接的类型断言:

1
2
let myNumber: string = "2333";
myNumber as any as string;

不建议使用这种断言方式。 尽管通过 any 可以绕过类型限制,进行任意两个类型的断言。但这很大概率会导致运行时错误。

函数

函数的类型

javascript 中,函数定义分为具名和匿名两种:

1
2
3
4
5
6
7
8
9
//具名函数
function add(x: number, y: number): number {
return x + y;
}

//匿名函数
let add = function (x: number, y: number): number {
return x + y;
};

那下面那个 add 是什么类型呐,怎么写出它的类型?

一个函数的类型由它的参数和返回值组成,像这样:

1
2
3
4
5
6
let add: (x: number, y: number) => number = function (
x: number,
y: number
): number {
return x + y;
};

当我们在变量的属性中指明类型后,可以省略后面函数定义中的类型:

1
2
3
let add: (x: number, y: number) => number = function (x, y) {
return x + y;
};

函数的参数

严格一致

typescript 中,函数的参数和 javascript 中函数的参数要求不一样。typescript 要求函数的参数调用时需要和定义时严格一致。

下面这个代码就会报错:

1
2
3
4
5
function add(x, y) {
return x + y;
}

add(1, 2, 3); // Expected 2 arguments, but got 3.

可选参数

有时候,我们希望有些参数可以不传,这时候,我们可以使用 ? 来表示一个可选参数:

1
2
3
4
5
6
function getDefaultNickName(id: number, name?: string): string {
if (name) return id + name;
else return id + "";
}

console.log(getDefaultNickName(233));

默认参数

默认情况下,如果没有传可选参数,那么它的值为 undefined,我们还可以为它指定默认参数:

1
2
3
4
function getDefaultNickName(id: number, name: string = ""): string {
return id + name;
}
console.log(getDefaultNickName(233));

默认参数也是可选的。需要注意一点,默认参数必须在普通参数的后面。

在类型定义中,默认参数值往往被省略,留下的是可选参数。例如,上面的例子的类型是 (id: number, name?: string) => string

剩余参数

js 一样,我们也可以使用 ... 来定义剩余参数:

1
2
3
function add(x: number, y: number, ...nums: number[]) {
//some code
}

剩余参数必须定义在参数的末尾。
当一个函数有了剩余参数时,它可以传入无数个参数。

this

javascript 中,this 是一个很灵活的东西。这使得 typescrcript 难以检测 this 的具体类型。我们可以显式指定一个 this 参数,来让 typescript 知道 this 的类型:

this 需要是第一个参数,这个参数只是为了识别类型用的。对 bindapply 等是没有影响的。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
interface User {
id: number;
name: string;
age: number;
scores: number[];
}

let user = {
id: 1,
name: "tom",
age: 18,
scores: [89, 90, 87],
getAverage(this: User) {
return (
this.scores.reduce((x: number, y: number) => x + y) / this.scores.length
);
},
};
user.getAverage();

重载

有时候,一个函数传入的参数的类型是有很多种的:

1
2
3
4
5
6
7
8
// 同类型的数据相加
function addNumbers(a: number, b: number): number {
return a + b;
}

function addStrings(a: string, b: string): string {
return String(Number.parseFloat(a) + Number.parseFloat(b));
}

我们可以使用联合类型来简化一下:

1
2
3
4
5
6
7
8
9
function add(a: string | number, b: string | number): number | string {
if (typeof a == "string" && typeof b == "string")
return parseFloat(a) + parseFloat(b);
if (typeof a == "number" && typeof b == "number") return a + b;
else throw new TypeError("type not expected!");
}

console.log(add(5, 16));
console.log(add("5", "16"));

但是这多了很多种情况,需要手动对不需要的类型情况进行排除。

这个时候,函数重载便其作用了。我们可以给一个函数进行多次声明,然后进行定义:

1
2
function add(a: string, b: string): string;
function add(a: number, b: number): number;

ts的重载和其他语言不一样, ts的重载只能有一个函数定义,且函数定义不在重载解析列表里面

也就是说,function add(a: string | number, b: string | number): number | string 是不算入重载解析依据的。

接口

介绍

接口(Interfaces) 用来定义对象的类型。

java 等传统的面向对象的语言中,接口是对对象行为的抽象,需要类去实现 (implement)。对于 typescript,接口不仅可以抽象对象的行为,也可以抽象对象的数据。

使用 interface 关键字来定义一个接口:

1
2
3
4
5
6
interface Student {
id: number;
name: string;
age: number;
scores: number[];
}

接着就可以使用这个接口:

1
2
3
4
5
6
let lisa: Student = {
id: 0,
name: "Lisa",
age: 23,
scores: [89, 97, 66],
};

使用接口定义对象时,必须和接口定义的属性保持一致,多了少了都会发生错误:

1
2
3
4
5
6
7
let lisa: Student = {
id: 0,
name: "Lisa",
age: 23,
email: "lisa@example.com", //Object literal may only specify known properties, and 'email' does not exist in type 'Student'.
scores: [89, 97, 66],
};

可选属性

如果希望某些属性既可以添加有可以不添加,则可以在属性名后面加上一个 ?,这称为可选属性(Optional Properties):

1
2
3
4
5
6
7
interface Student {
id: number;
name: string;
age: number;
scores: number[];
email?: string;
}

只读属性

如果希望这个属性无法修改,则可以定义为只读属性 (Readonly Properties):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
interface Student {
readonly id: number;
name: string;
age: number;
scores: number[];
email?: string;
}

let lisa: Student = {
id: 0,
name: "Lisa",
age: 23,
email: "lisa@example.com",
scores: [89, 97, 66],
};

lisa.id = 1; //Cannot assign to 'id' because it is a read-only property.

对于数组,将其设置为 readonly 只会避免该属性被修改,而数组的值仍然是可变的:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
interface Student {
readonly id: number;
name: string;
age: number;
readonly scores: number[];
email?: string;
}

let lisa: Student = {
id: 0,
name: "Lisa",
age: 23,
email: "lisa@example.com",
scores: [89, 97, 66],
};

lisa.scores.push(100); // ok

如果希望数组的内容不可变,可以使用 ReadonlyArray<type> 来定义一个数组

属性检查

有时候希望一个接口可以定义任意多的属性,这时候可以定义一个 [propName: string] 属性用来表示任意的属性:

1
2
3
4
5
6
7
interface Student {
readonly id: number;
name?: string;
age?: number;
readonly scores: number[];
[propName: string]: any;
}

不过需要注意的是,这种特殊的属性会检查所有的属性:

1
2
3
4
5
6
7
interface Student {
id: number; //error
name?: string;
age?: number; //error
readonly scores: number[];
[propName: string]: string;
}

使用接口定义数组

我们可以通过定义一个 [propName: number] 属性用来表示数组下标的值:

1
2
3
4
5
6
7
interface StringArray {
[index: number]: string;
}

let arr: StringArray = ["Tom", "Siri"];

let myStr: string = arr[0];

我们甚至可以同时定义支持整数属性和字符串属性的接口:

1
2
3
4
interface StringArray {
[index: number]: string;
[propName: string]: string;
}

不过,需要注意一点,整数下标值的类型必须是字符串下标值的子类型

这是因为 javascript 在访问整数下标时,会自动转换成相应的字符串下标。

1
2
3
4
interface StringArray {
[index: number]: number;
[propName: string]: string; // Numeric index type 'number' is not assignable to string index type 'string'.
}

使用接口定义函数

接口不仅可以定义对象的模板,也可以定义函数的模板。我们可以通过圆括号来定义一个函数:

1
2
3
4
5
6
7
8
9
interface Sorter {
(a: number, b: number): number;
}

let asc: Sorter = function (a: number, b: number): number {
if (a > b) return -1;
if (a < b) return 1;
else return 0;
};

编译器只会检查参数的个数、位置和类型以及返回值的类型是否正确,参数名称不要求完全一样。

使用接口定义的函数,可以省去参数和返回值类型,因为在接口中已定义过:

1
2
3
4
5
let asc: Sorter = function (a, b) {
if (a > b) return -1;
if (a < b) return 1;
else return 0;
};

索引类型

有时候我们想限制一个变量的类型只能是一个接口具有的一些索引,这时候我们可以使用 keyof 运算符,来获取一个接口的索引类型:

1
2
3
4
5
6
7
interface Car {
manufacturer: string;
model: string;
year: number;
}

type CarIndex = keyof Car; // "manufacturer" | "model" | "year"

如果规定了字符串的属性检查,则它的索引类型自动变为 string | number:

1
2
3
4
5
6
7
8
9
interface Student {
id: number; //error
name?: string;
age?: number; //error
readonly scores: number[];
[propName: string]: string;
}

type StudentIndex = keyof Student; // string | number

如果仅规定了数字的属性检查,则它的索引类型自动变为 number

1
2
3
interface Arr {
[key: string]: any;
}

类和对象

属性和方法

介绍

javascript是一个基于原型的对象系统,通常通过一个构造函数来创建一个对象,通过原型和原型链来实现继承。随着ES6 的出台, javascript 中面向对象的语法得到了拓充,我们可以使用其他面向对象语言中的一些语法来编写 javascript 中的类。

我们可以使用 class 语法来定义一个简单的类:

1
2
3
4
5
6
7
8
9
class Student {
id: number;
name: string;

constructor(id, name) {
this.id = id;
this.name = name;
}
}

其中,constructor 称为 构造函数,当构造对象时,便会调用这个方法:

1
let tom: Student = new Student(1, "Tom");

访问限制

typescript 提供了三个限制符用于限制成员的访问:

关键字谁可以访问
public任何人
private类自身
protected类自身和派生类
1
2
3
4
5
6
7
8
9
class Student {
public id: number;
private name: string;

public constructor(id, name) {
this.id = id;
this.name = name;
}
}

不过,其他语言不同的是,如果不给属性和方法指定任何限制符,则它们默认是 public

javascript 的类语法中,成员默认都是公有的,typescript 也继承了这一点

1
2
3
4
5
6
7
8
9
class Student {
id: number; //public
name: string; //public

constructor(id, name) {
this.id = id; //public
this.name = name; //public
}
}

我们也可以给成员名前面加上 # 来将成员设置成私有:

1
2
3
4
5
6
7
8
9
class Student {
id: number;
#name: string;

public constructor(id, name) {
this.id = id;
this.name = name;
}
}

只读属性

属性和变量相似,可以为他们加上 readonly ,也可以为他们赋初值:

实际上,类中的 readonly 属性是可以在 constructor 中赋值的。

readonly 属性需要写在限制符的后面。

1
2
3
4
5
6
7
8
9
class Student {
public readonly id: number = 1;
name: string;

constructor(name) {
this.id++;
this.name = name;
}
}

参数属性

一个类中的很多属性,需要在对象创建的时候赋初值。
这个时候,我们便可以给参数加上限制符或者readonly,让一个构造函数的参数成为一个属性:

1
2
3
class Student {
constructor(readonly id, public name) {}
}

当对象初始化的时候,idname 便自动成了对象的属性。相比于之前的写法,这种写法就显得十分简洁。

静态成员

typescript 中的静态属性相当于 python 中的类属性。

有时候,我们并不需要在实例上添加属性和方法,而是在类上添加。这个时候,我们可以使用 static 关键字来定义这种属性和方法:

1
2
3
4
class Point {
static origin = new Point(0, 0);
constructor(public x, public y) {}
}

这个时候,便可以直接通过类访问这个属性:

1
console.log(Point.origin);

但需要注意一点,实例是不可以访问这个属性的

1
console.log(new Point(2, 3).origin); //Property 'origin' is a static member of type 'Point'.

访问器属性

typescript 中的对象,有两种属性,一种是 数据属性 ,另外一种便是 访问器属性

访问器属性 实际上是获取和设置值的函数,但看上去像普通属性。其中:

  • get 获取数据 x.xx
  • set 修改数据 x.xx = xxx

我们通过给函数加上 getset 来定义一个访问器属性:

1
2
3
4
5
6
7
8
9
10
11
class Student {
constructor(private _id: number) {}
get id() {
return this._id;
}

set id(value: number) {
if (value < 0) throw Error("id >= 0 !");
this._id = value;
}
}

这样,我们可以直接访问和修改 id

1
2
3
let tom: Student = new Student(2333);
console.log(tom.id); //2333
tom.id = -1; //error

getset 函数都是可选的,当缺少相应的函数时,相应的功能不发挥作用。

例如,只定义 get 函数时,这个属性是只读的:

1
2
3
4
5
6
7
8
9
10
class Student {
constructor(private _id: number) {}
get id() {
return this._id;
}
}

let tom: Student = new Student(2333);
console.log(tom.id); //2333
tom.id = -1; //Cannot assign to 'id' because it is a read-only property.

继承

介绍

继承这种现象是非常普遍的。比如,我们都喜欢基于现成的软件,修改一下,让这种软件符合我们的口味。这样做,就避免了为开发符合口味的软件而重新开发所有东西,从而减少了人力。

typescript 中,一个类只能继承另一个类。

typescript 中,我们可以使用 extends 来轻松的实现类的继承:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
class Animal {
constructor(public name: string) {}
eat() {
console.log(this.name, "ate somethong.");
}
}

class Dog extends Animal {
constructor(name: string) {
super(name);
}

bark() {
console.log(this.name, "barked.");
}
}

super

对于派生类,如果拥有构造函数,则需要调用 super 函数来调用基类的构造函数。而且,在使用 this 之前必须调用 super 函数。如果没有构造函数,则使用基类的构造函数。

重写

我们可以在派生类中定义与基类相同的方法。此时,派生类的方法会覆盖基类的方法,无论参数和返回值是否相同:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
class Animal {
constructor(public name: string) {}
eat() {
console.log(this.name, "ate somethong.");
}
}

class Dog extends Animal {
eat() {
console.log("A dog called", this.name, "ate somethong.");
}

bark() {
console.log(this.name, "barked.");
}
}

let dog: Dog = new Dog("Tom"); // A dog called Tom ate somethong.
dog.eat();

多态

介绍

多态让不同的对象,同一个行为具有不同的表现。

就像家里的插座一样,给空调通电,就可以给屋子带来凉气;给电饭煲通电,就可以给饭加热。

但插座(排开功率)是不认哪个电器的,插座的生产商只需要这个电器实现了相同的插孔,就可以让电器运行起来,而不必考虑这是哪个电器。

我们把这个例子用typescript 写出来康康:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
class Equipment {
run() {
console.log("设备开始运行");
}
}

class RiceCooker extends Equipment {
run() {
console.log("电饭煲开始加热");
}
}

class AirConditioner extends Equipment {
run() {
console.log("空调开始制冷");
}
}

let equipment: Equipment = new RiceCooker();
equipment.run();

equipment = new AirConditioner();
equipment.run();

上面的 equipment 是家用电器,具有 run 方法,相当于上面的把插头插上去。在 RiceCookerAirConditioner 中,分别实现了插上电源时的不同操作。而且, equipment 是不考虑接受的具体是哪个电器。

我们稍微抽象一下,多态,就是让派生类重写基类的方法,然后创建一个对象赋值给基类对象,再通过基类的对象来调用这个方法,而实际上,调用的是派生类的方法,从而产生不同的结果。

那我们为什么称它为多态呢?多态的字面含义是多种状态,而基类变量不仅仅传入基类的对象,也可以传入派生类的对象。这样,我们便可说,基类变量可以有多种状态。

typescript 中,想要实现多态,则需要有三个条件:

  • 继承类
  • 重写相应的函数
  • 将派生类赋值给基类变量

在其他面向对象的语言中,我们通常把 将派生类赋值给基类变量 称为 向上转型 。但是基类变量只能访问基类拥有的属性和方法。

不过,别忘了 typescript 是按照结构来判断继承关系的,所以 extends 实际上是可以删掉的,typescript 仍会认为RiceCooker 继承了 Equipment

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
class Equipment {
run() {
console.log("设备开始运行");
}
}

class RiceCooker {
run() {
console.log("电饭煲开始加热");
}
}

class AirConditioner {
run() {
console.log("空调开始制冷");
}
}

有向上转型自然也有向下转型,看下面的代码:

1
2
let equipment: Equipment = new RiceCooker();
let riceCooker: RiceCooker = equipment; // 向下转型

不过,向下转型得有个要求,就是接受对象的实际类型必须是变量的子类型(或者是相同的类型)。例如,上面 equipment 实际类型是 RiceCooker,就自然可以赋值给 RiceCooker 的对象。

抽象类

有时候,有些东西并不是给用户使用的,而是希望用户在这个基础上添加一些东西,再使用。这就是抽象类。

typescript 中,抽象类用于定义一些"模板",它本身无需实现,而是交给派生类去实现。这些模板便是抽象方法。

typescript 中,定义一个抽象类是很简单的:

1
2
3
4
abstract class Equipment {
private name: string;
abstract run(): void;
}

抽象类本身是不可以创建对象的。

这个时候,run 的实现就交给派生类了:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
class RiceCooker extends Equipment {
private _mode: "HEAT" | "PRESERVE";
run() {
console.log("电饭煲开始加热");
}

get mode() {
return this._mode;
}

set mode(_mode: "HEAT" | "PRESERVE") {
this._mode = _mode;
}
}

需要注意一点,非抽象的派生类需要实现全部的抽象方法。否则会报错。

类和接口

类实现接口

类可以通过 implements 关键字来实现接口。

实现接口和继承不一样,实现接口需要在类中重新定义相应的数据,而且一个类可以实现多个接口:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
interface UserInfo {
id: number;
name: string;
age: number;
}

interface UserAuth {
phone: number;
email: string;
password: string;
}

class User implements UserInfo, UserAuth {
id: number;
name: string;
age: number;
phone: number;
email: string;
password: string;
}

接口继承接口和类

接口和类一样,也可以通过 extends 实现继承,而且接口可以继承多个接口的:

1
2
3
4
5
6
7
8
9
10
11
12
13
interface UserInfo {
id: number;
name: string;
age: number;
}

interface UserAuth {
phone: number;
email: string;
password: string;
}

interface User extends UserInfo, UserAuth {}

接口甚至可以继承多个类:

1
2
3
4
5
6
7
8
9
10
11
12
13
class UserInfo {
id: number;
name: string;
age: number;
}

class UserAuth {
phone: number;
email: string;
password: string;
}

interface User extends UserInfo, UserAuth {}

构造函数

当声明一个类时,实际上同时声明了很多东西。

当类名作为类型时,它表示实例的类型;
当类名作为值时,它表示构造函数:

1
2
3
4
5
6
7
8
9
10
11
12
class Student {
id: number;
name: string;

constructor(id, name) {
this.id = id;
this.name = name;
}
}

let x: Student; // x 是实例类型
let y: typeof Student; //y 是类的构造函数的类型

typeof 用于类型的语境中,它用于获取一个变量的类型。

而构造函数的类型和普通函数的类型有些区别,构造函数类型有一个 new 前缀,而且通常省略返回值类型:

1
2
3
interface StudentConstructor {
new (id, name);
}

有时我们可能会好奇的将 Student 继承 StudentConstructor,但这会报错:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
interface StudentConstructor {
new (id, name);
}

// Type 'Student' provides no match for the signature 'new (id: any, name: any): any'.
class Student implements StudentConstructor {
id: number;
name: string;

constructor(id, name) {
this.id = id;
this.name = name;
}
}

一个类是分为它的静态类型和实例类型的,静态类型实际上就是构造函数的类型,而实例类型则是类本身。

而接口本身应该要反映在实例对象的,所以在 implements 中,我们需要的是实例类型,而不是静态类型。

那如何验证构造函数的类型呢?前文提到,当类名作为值时,它表示构造函数。所以,我们可以使用类表达式:

1
2
3
4
5
6
7
8
9
10
11
12
13
interface StudentConstructor {
new (id, name);
}

let Student: StudentConstructor = class Student {
id: number;
name: string;

constructor(id, name) {
this.id = id;
this.name = name;
}
};

类和接口的声明合并

  1. 多个同名的接口定义会自动合并
1
2
3
4
5
6
7
8
9
interface Person {
name: string;
}

interface Person {
age: number;
}

let age: Person = { name: "", age: 1 };
  1. 同名的类和接口,定义自动合并
1
2
3
4
5
6
7
interface Person {
name: string;
}

class Person {
age: number;
}

泛型

介绍

有时候我们定义一个函数、接口或者类的时候,可能并不需要非常具体的属性或参数,而是保证属性或参数是相同的,或者具有某种关系。

比如说,尽管我们可以直接使用 Array<any>,但数组的元素就可以是任意类型了。有时候这并不是想要的,有时希望一个数组的元素只能是一个类型,但对每个类型都定义一种数组,又显得不太灵活。因为每增加一种类型,就要重新写很多代码。那有没有办法,只定义一种数组,但不指定数组元素的类型,等到需要的时候再来指定? 泛型就是这个问题的答案。

泛型便是在定义函数、接口和类的时候,定义一种特殊类型,这种类型是事先不确定的,直到使用的时候才能确定。

函数与泛型

我们可以给函数名后面增加一对尖括号和类型参数,来表示泛型:

1
2
3
function identity<T>(arg: T): T {
return arg;
}

我们还可以定义多个类型参数:

1
2
3
function identity<T, U>(arg1: T, arg2: U): [T, U] {
return [arg1, arg2];
}

我们可以像原来的方式调用函数,这个时候编译器会推断参数的类型:

1
console.log(identity("myString")); // 自动指定,判断出的类型是 "myString"

我们也可以手动指定类型参数:

1
console.log(identity<string>("myString")); // 手动指定

类型参数

当我们使用类型参数时,这个参数在使用前是不确定的,所以我们不能直接使用它的属性和方法。

1
2
3
function identity<T>(arg: T): number {
return arg.length; // Property 'length' does not exist on type 'T'.
}

但我们可以为泛型添加一个约束,让他必须有 length 属性:

1
2
3
4
5
6
7
interface HasLength {
length: number;
}

function identity<T extends HasLength>(arg: T): number {
return arg.length; // Property 'length' does not exist on type 'T'.
}

有时候编译器无法推断出泛型参数的值,而且用户也未指定时,我们可以定义一个默认的类型参数:

1
2
3
4
5
6
7
interface HasLength {
length: number;
}

function identity<T extends HasLength = number[]>(arg: T): number {
return arg.length; // Property 'length' does not exist on type 'T'.
}

类、接口和泛型

泛型不仅仅适用于函数,也适用于接口和类。

类、接口与函数不同,在创建对象时需要手动指定泛型参数的值。

使用类:

1
2
3
4
5
6
7
8
9
10
class MyArray<T> {
elements: T[];
}

// 两种定义对象的方法
let myArray1: MyArray<number> = new MyArray();
let myArray2: MyArray<number> = new MyArray<number>();

// 单独写需要加上类型参数
new MyArray<number>();

使用接口:

1
2
3
4
5
interface MyArray<T> {
elements: T[];
}

let myArray: MyArray<number>;

装饰器

介绍

装饰器是一项试验性特性,未来版本可能会发生改变

要启用装饰器,需要开启 experimentalDecorators 编译选项

命令行:

1
tsc --target ES5 --experimentalDecorators

tsconfig.json:

1
2
3
4
5
6
{
"compilerOptions": {
"target": "ES5",
"experimentalDecorators": true
}
}

装饰器是一种附加到类、方法、访问器、属性或者参数的一种声明。

装饰器通常使用 @expression 的形式,且 expression 需要求值为函数,在运行的时候被调用。

多个装饰器即可以写在一行上,也可以写在多行上:

1
2
3
4
@f
@g
x
@f @g x

在运行的时候,这就相当于复合函数求值: f(g(x))

类装饰器

类装饰器在类声明之前声明,它应用于构造函数,可以修改类的定义。

类装饰器的参数是构造函数。如果有返回值,则返回值会被用于构造函数。

例如,我们可以使用类装饰器来修改构造函数,使得每创建一个实例,id自动加一:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
function autoIncrease<T extends { new (...args: any[]): {} }>(constructor: T) {
let id = 0;
return class extends constructor {
id: number;
constructor(...args) {
super(...args);
id++;
this.id = id;
}
};
}

@autoIncrease
class User {
id: number;
name: string;
age: number;
}

console.log(new User(), new User(), new User());

方法装饰器

方法装饰器在方法声明之前声明,它应用于方法的属性描述符上,可以修改方法的定义。

方法装饰器具有三个参数:

  1. 构造函数(静态方法)、原型对象(实例方法)
  2. 成员的名字
  3. 成员的属性描述符

如果方法装饰器有返回值,则返回值作为方法的属性描述符。

javascript 对象的属性描述符一共有 6 种:

  • value :属性的值
  • get :获取器
  • set :修改器
  • writable:如果为 true,则值可以被修改,否则它是只可读的。
  • enumerable : 如果为 true,则会被在 for-in 循环和 Object.keys 中列出,否则不会被列出。
  • configurable: 如果为 true,则其他属性描述符不可修改且不可删除

例如,我们可以使用修饰器将一个函数变为 getter

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
function getter(target: any, prop: string, descriptor: PropertyDescriptor) {
return {
get: target[prop],
};
}

class Student {
id: number;
name: string;
scores: number[];

@getter
average() {
return this.scores.reduce((x, y) => x + y) / this.scores.length;
}
}

let student = new Student();
student.scores = [89, 95, 78];
console.log(student.average);

访问器修饰器

访问器修饰器在访问器声明之前声明,它应用于访问器的属性描述符上,可以修改访问器的定义。

需要注意一点,访问器修饰器不可以同时装饰一个成员的 getset,而应该装饰在定义的第一个访问器上。

访问器修饰器的参数和返回值同 方法装饰器

属性装饰器

属性装饰器 声明在一个属性声明之前(紧靠着属性声明)。

属性装饰器具有两个个参数:

  1. 构造函数(静态方法)、原型对象(实例方法)
  2. 成员的名字

属性修饰器的返回值会被忽略。

属性在类中是无法通过属性描述符描述的,因为只有创建对象时,这些属性才会存在。所以目前无法在定义类时描述一个实例属性,也没办法监视和修改一个属性的初始化方法。

参数修饰器

参数装饰器 声明在一个参数声明之前(紧靠着参数声明),用于类的构造函数或方法声明。

参数装饰器表达式会在运行时当作函数被调用,传入下列 3 个参数:

  1. 对于静态成员来说是类的构造函数,对于实例成员是类的原型对象。
  2. 成员的名字。
  3. 参数在函数参数列表中的索引。

参数修饰器的返回值会被忽略。

模块

介绍

模块是 ES6 引入的一个概念。 tses 一样,只要文件包含 importexport ,就会被当做一个模块,否则该文件是全局可见的。

ES6 风格的导入导出

导出声明

任何变量、函数、类、类型、接口、命名空间的声明都可以在前面加上 export 关键字来实现导出:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
export class UserInfo {
id: number;
name: string;
age: number;
}

export interface UserAuth {
phone: number;
email: string;
password: string;
}

export let count = 1;
export type User = UserInfo & UserAuth;
export let user: User;

其中,export 关键字只是表示这个声明被导出了,这些声明依然可以在当前文件中使用。

除了给声明加上 export 关键字,也可以使用 export 语句来导出一个内容。不过,export 关键字只能导出已有的声明。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
class UserInfo {
id: number;
name: string;
age: number;
}

interface UserAuth {
phone: number;
email: string;
password: string;
}

let count = 1;
type User = UserInfo & UserAuth;
let user: User;

export { UserInfo, UserAuth, User, count, user };

导出语句

export 语句可以有多个:

1
2
export { UserInfo, UserAuth, User };
export { count, user };

利用 export 语句,还可以给声明重命名:

1
export { count, user as defaultUser };

这样就导出了 countdefaultUser 两个声明。

重新导出

可以使用 export from 语句来从其他模块中导入数据,并重新导出:

1
export { count, user as defaultUser } from "./user";

如果需要导出全部的内容,可以使用 *

1
export * from "./user";

导入

我们可以使用 import from 语句来导入部分内容,也可以使用 as 来重命名:

1
import { count, user as defaultUser } from "./user";

同样的,我们也可以使用 * 来导入全部内容,但是我们需要给导入的内容起个名字:

1
import * as user from "./user";

导入的数据是不可以直接赋值的。

1
2
import { user, User } from "./app";
user = 1; // Cannot assign to 'user' because it is not a variable.

有时候我们导入模块只是想运行一些代码,而不需要模块中的内容,这个时候就可以只写 import

1
import "./app";

默认导出

有时候,用户在模块中只有一个输出,这个时候便可以使用 export default 来导出单个数据,也可以导出一个值:

1
export default User;

这个时候,import 语句便无需括号,而且可以使用任意的名字:

1
import myUser from "./user";

CommonJS 风格的导入导出

typescript 也提供了这种风格的导入导出。

但需要注意一点,这种风格的导入和导出是配套使用的。不能和上面的写法混用。

导出

我们可以使用 export = 语法来定义一个模块的导出对象:

1
export = 2333;

导入

我们可以使用importrequire 来导入模块:

1
import user = require("./user");

这里的 require 只是使用了括号的语法糖,它并不是函数。

动态导入

我们可以使用 require() 来动态加载一个模块,不过需要先声明 require 函数:

1
2
3
declare function require(moduleName: string): any;

let user = require("./user");

外部模块

介绍

typescript 默认情况下,只能识别 .ts.tsx 结尾的模块,但项目实战中,往往会出现其他类型的模块。

例如,在一个典型的 Vue 项目中,会出现很多 .vue 文件。在 javascript 等弱类型语言中,这些特殊的模块往往是通过 Webpackloader来实现。

但是 typescript 加上了类型,如果直接引入,就会出现类型错误,因为 typescript 不认得 .vue 文件,也就不知道导入后文件的类型了:

1
import App from "./App.vue"; // Cannot find module './App.vue' or its corresponding type declarations.

此外,很多库都不是使用 typescript 编写的,如果用 typescript 导入,也不能识别类型。

为了解决这个问题,便出现了 外部模块 。外部模块是通过声明文件来完成的,它是一个 .d.ts 文件。声明文件相当于 C 和 C++中的 .h 文件,它只用来声明类型,而不去具体实现这些类型。

声明模块

我们在这个文件中使用 declare module 关键字来声明一个模块:

node.d.ts
1
2
3
4
5
6
7
8
9
10
11
12
13
declare module "url" {
export interface Url {
protocol?: string;
hostname?: string;
pathname?: string;
}

export function parse(
urlStr: string,
parseQueryString?,
slashesDenoteHost?
): Url;
}

接下来,我们可以使用三斜杠语法引入文件,就可以使用了:

1
2
3
/// <reference path="node.d.ts"/>
import * as URL from "url";
let myUrl = URL.parse("http://www.typescriptlang.org");

简写

如果不想编写具体声明,也可以简写:

node.d.ts
1
declare module "url";

但导入后,它的类型会被识别为 any

1
import { parse } from "url"; // parse 是 any 类型

通配符

上面的情况只解决了其中一种问题,但未解决 .vue 等模块的问题。

typescript 还提供了一种通配符模块,这样就可以让 typescript 识别.vue 等模块了:

shims-vue.d.ts
1
2
3
4
5
declare module "*.vue" {
import { ComponentOptions } from "vue";
const componentOptions: ComponentOptions;
export default componentOptions;
}

导出到全局变量

有些模块往往是支持多个模块加载器,也支持通过全局变量访问。对于这种模块,我们可以将其导出到全局变量:

validator.d.ts
1
2
export function isEmail(x: string): boolean;
export as namespace validator;

在模块中,我们可以通过 import 来导入:

1
import { isEmail } from validator;

在模块之外,我们可以直接当成全局变量使用:

1
validator.isEmail("a@example.com");

命名空间

介绍

和其他语言一样,命名空间是用来解决全局作用域中重名问题,避免全局污染。

typescript 中的命名空间相当于成一个对象,通过这个对象,我们可以访问命名空间作用域下的内容。

定义命名空间

我们可以使用 namespace 关键字声明一个命名空间:

1
2
3
4
namespace Validator {
declare function validateString(x: string): boolean;
declare function validateUser(x: User): boolean;
}

需要注意一点,如果需要命名空间外可以访问里面的内容,需要加上 export 关键字:

1
2
3
4
namespace Validator {
export declare function validateString(x: string): boolean;
export declare function validateUser(x: User): boolean;
}

然后通过访问对象的方法访问命名空间:

1
Validator.validateString("test");

如果命名空间在单独的一个文件,我们可以使用一个三斜杠语法来引入一个命名空间:

1
2
/// <reference path = "validators.ts" />
Validator.validateString("test");

命名空间别名

有时候命名空间是嵌套的,像这样:

1
2
3
4
namespace Validator {
export namespace BaseValidator {}
export namespace ObjectValidator {}
}

这个时候我们可以使用 import 关键字来为他们导入数据:

1
import BaseValidator = Validator.BaseValidator;

需要注意,这里没有 require 关键字。

命名空间和模块

在早期的 typescript 中,具备了两套模块系统,分别是内部模块和外部模块。其中,外部模块就是把一个文件当成一个模块,类似于 AMDESM,。而内部模块,则与文件划分无关,只是单纯的隔离的作用域。

随着 ES6 的发布,ts 弃用了自己的模块系统,转而使用 ES6 的模块系统,这时外部模块改名叫模块;而内部模块则称为命名空间。

实际上,命名空间缺少很多模块的写法,难以识别它们的依赖关系。而模块则解决了这些问题,但使用模块需要有模块加载器,也增加了一些胶水代码。

对于比较简单的场景,使用模块可能会使代码过于冗余,使用命名空间可能会较好;而对于复杂的场景,使用模块便会有更好的可维护性。

如果选择使用模块,不要在里面使用命名空间。因为模块是一个单独的作用域,在里面使用命名空间不会有任何价值,而且会带来不必要的麻烦。

高级类型

联合类型

联合类型 (Union Types) 表示取值可以为多种类型中的一种。在变量定义时,类型使用 | 分开:

1
let myNumber: string | number;

当编译器无法确定变量的具体类型时,只能访问此联合类型中,所有类型里共有的属性或方法:

1
2
let myNumber: string | number;
console.log(myNumber.length); //Property 'length' does not exist on type 'string | number'. Property 'length' does not exist on type 'number'.

在赋值时,它的具体类型就会被确定,就可以使用具体类型的方法:

1
2
let myNumber: string | number = "2333";
console.log(myNumber.length);

共有的属性,也会跟着发生联合:

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
type NetworkLoadingState = {
state: "loading";
};

type NetworkFailedState = {
state: "failed";
code: number;
};

type NetworkSuccessState = {
state: "success";
data: {
id: number;
title: string;
content: string;
};
};

type NetworkState =
| NetworkLoadingState
| NetworkFailedState
| NetworkSuccessState;

let state: NetworkState;
state.state; // 类型:"loading" | "failed" | "success"

类型和它的子类型联合得到类型本身,不过前提是这两个类型有一个是原始类型:

1
type A = number | null; // number

类型守卫

介绍

类型守卫,用于判断一个类型是否是用户要求的类型。

说它是守卫,这是因为它像极了看城门的守卫。这些看城门的任务是让符合要求的人进来,不符合要求的拒之门外,或者让他们干别的事情。

而类型守卫,就是让 typescript 判断一个值是一些类型(通常是个联合类型)中的哪一个。

定义类型守卫

对于联合类型,类型判断是有点麻烦的。在确定它的类型之前,我们只能访问共有的属性。要访问某一个类型的属性,就要确定它是其中的一个类型。

我们只需要简单的定义一个函数,它的返回值是一个类型谓词:

1
2
3
function isStudent(people: Student | Teacher): people is Student {
return (people as Student).scores !== undefined;
}

这个函数便是类型守卫。当我们需要判断类型的时候,使用这个函数,typescript 便会自动认定类型:

1
2
3
4
5
if (isStudent(people)) {
console.log(people.scores); // people 就是 Student
} else {
// people 是 Teacher
}

除了使用类型断言来判断类型,也可以使用 in 操作符:

1
2
3
function isStudent(people: Student | Teacher): people is Student {
return "scores" in people;
}

typeof 类型守卫

对于原始类型,可以通过 typeof 来判断出它的类型,就不必要让用户去自定义类型守卫。

typeof类型守卫只有两种形式能被识别:typeof v === "typename"typeof v !== "typename",而且 "typename"必须是"number""string""boolean""symbol"。 但是TypeScript 并不会阻止用户与其它字符串比较,语言不会把那些表达式识别为类型守卫。

instanceof 类型守卫

对于类类型,可以通过 instanceof 来判断出它的类型,也没必要让用户去自定义类型守卫。

交叉类型

使用 &把多个类型合并成一个类型,得到的类型称为交叉类型 (Intersection Types):

1
2
3
4
5
6
7
8
9
10
11
12
13
interface UserInfo {
id: number;
name: string;
age: number;
}

interface UserAuth {
phone: number;
email: string;
password: string;
}

type User = UserAuth & UserInfo; //交叉类型

交叉类型拥有所有类型中都有的属性:

1
2
3
4
5
6
7
8
let tom: User = {
id: 1,
name: "Tom",
age: 18,
phone: 12345678901,
email: "tom@example.com",
password: "123456",
};

同名属性的类型同样会发生交叉:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
interface UserInfo {
id: number;
name: string;
age: number;
}

interface UserAuth {
id: number;
phone: number;
email: string;
password: string;
}

type User = UserAuth & UserInfo; //交叉类型中,id 仍然发生交叉,得到 number 类型

两个原始类型从语义上是无法交叉的( stringnumber 能交叉吗?),将其交叉将得到 never 类型:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
interface UserInfo {
id: string;
name: string;
age: number;
}

interface UserAuth {
id: number;
phone: number;
email: string;
password: string;
}

type User = UserAuth & UserInfo; //交叉类型中,id 的类型为 never

运算规律

此处官方文档并未说明,只是将数学中的某些概念引入以辅助理解

幂等律

幂等律 的含义就是对一个数的重复次运算等于它本身。

在交叉类型和联合类型中,自己和自己交叉(联合) 得到自身:

1
2
type A = number | number;
type B = number & number;

零律

零律 就是满足 bx=xb=b 的式子,即一个数和一个常数的某个运算得到常数。

在交叉类型和联合类型中,类型和 any 交叉(联合) 得到 any

1
2
type A = number | any; // any
type B = number & any; // any

映射类型

有时候,我们希望需要一个现有的对象的只读版本或者必须版本,这个时候我们可以创建一个映射类型。 在映射类型里,新类型以相同的形式去转换旧类型里每个属性:

1
2
3
type Readonly<T> = {
readonly [P in keyof T]: T[P];
};

需要注意一点,映射类型里不可以添加新的成员:

1
2
3
type Readonly<T> = {
readonly [P in keyof T]: T[P]; // error
};

如果需要新的成员,则需要使用 交叉类型

1
2
3
4
5
type Readonly<T> = {
readonly [P in keyof T]: T[P];
} & {
prop: boolean;
};

有条件类型

介绍

在类型定义语句中,我们可以通过三目运算符来定义一个带条件的类型:

1
T extends U ? X : Y

它得到的类型要么是 X,要么是 Y,如果无法判断条件,将会延迟解析。

例如:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
type TypeName<T> = T extends string
? "string"
: T extends number
? "number"
: T extends boolean
? "boolean"
: T extends undefined
? "undefined"
: T extends Function
? "function"
: "object";

type str = TypeName<String>; // 立即解析
declare function f<T>(arg: T): TypeName<T>; // 延迟解析

未确定的有条件类型可以赋值给联合类型:

1
2
3
4
5
function g<T>(arg: T) {
let x = f(arg);
let y: "string" | "number" | "boolean" | "undefined" | "function" | "object" =
x;
}

分布式的有条件类型

如果有条件类型中的类型参数是个联合类型,则联合类型中的每一个类型都会分别进行类型推断,最终得到一个联合类型。这称为 分布式的有条件类型

例如:

1
type Type = TypeName<string, number>;

它将展开成:

1
2
3
4
5
6
7
8
9
10
11
12
type Type = (string extends string ? "string" :
string extends number ? "number" :
string extends boolean ? "boolean" :
string extends undefined ? "undefined" :
string extends Function ? "function" :
"object") | (
number extends string ? "string" :
number extends number ? "number" :
number extends boolean ? "boolean" :
number extends undefined ? "undefined" :
number extends Function ? "function" :
)

通过有条件类型,我们可以实现对字面量类型的过滤:

1
2
type Diff<T, U> = T extends U ? never : T;
type T1 = Diff<"a" | "b" | "c" | "d", "a" | "b" | "c">; // 'd'

含类型推断的有条件类型

有时候 extend 子句中出现的是函数,但是函数的返回值不确定,这个时候我们可以为 extends 子句添加类型推断:

1
type ReturnType<T> = T extends (...args: any[]) => infer R ? R : any;

这个类型就实现了获取任意函数返回值类型。

对于如果extend 子句是数组,也可以进行类型推断:

1
type UnpackArr<T> = T extends (infer R)[] ? R : any;

这个类型就实现了对数组类型的解包。

类型兼容性

子类型的概念

当类型A 的性质(或属性)比类型 B 的性质(或属性)更多时,便称 AB子类型(subtype)。

结构子类型

TypeScript 是一个基于结构化的类型系统,只要结构一致,即使没有在语义上声明子类型关系,编译器也认为这是赋值兼容的。

1
2
3
4
5
6
7
8
9
10
interface IPeople {
name: string;
age: number;
}

class People {
constructor(public name: number, public age: number) {}
}

let p: IPeople = new People("Kaz", 19);

上面的这个例子中,People 没有显式继承 IPeople,但仍然可以将People对象赋值给 IPeople

在 TypeScript 中,这称为 结构子类型。而 java 等语言则是 名义子类型

内置类型的子类型关系

下面的图形展示了 typescript 中各种常见类型的子类型关系。其中 A-->B 记作 AB 的子类型,此时A 类型的数据赋值可以赋值给 B

graph LR
Tuple-->Array
Array-->object
Enum-->object

never-->undefined

undefined-->null
null-->undefined

null-->number
null-->bigint
null-->string
null-->boolean
null-->Tuple
null-->Enum
null-->void

变体

在由父类BB 和子类CC 组合的复杂类型中,存在着复杂的类型兼容性,这些兼容性存在着四种情况:

  1. 协变(Covariant):只在同一个方向(向下);

例如,A 有子类 B,B 有子类 C。在协变的情况下,输入B的位置,可以输入C,但不能输入A。

  1. 逆变(Contravariant):只在相反的方向(向上);

例如,A 有子类 B,B 有子类 C。在逆变的情况下,输入B的位置,可以输入A,但不能输入C。

  1. 双向协变(Bivariant):包括同一个方向和不同方向(向上、向下均可);

例如,A 有子类 B,B 有子类 C。在双向协变的情况下,输入B的位置,可以输入A,也可以输入C。

  1. 不变(Invariant):如果类型不完全相同,则它们是不兼容的。

例如,A、B和C没有继承关系。在不变的情况下,输入B的位置,不能输入A和C。

比较字面量类型

例如下面的例子中,Method1 的内容比 Method2 更具体,内容更少,所以 Method1Method2 的子类型。

这和一般基于集合的理解是相反的。

1
2
3
4
5
6
7
8
type Son = 'GET' | 'POST'
type Parent = 'GET' | 'POST' | 'PUT' | 'DELETE'

let parent: Parent
let son: Son

parent = son // ok
son = parent // error

比较两个函数

函数的返回值

函数的返回值属于 协变,因为返回的内容必须不少于定义的内容。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
interface Point1D {
x: number;
}

interface Point2D {
x: number;
y: number;
}

interface Point3D {
x: number;
y: number;
z: number;
}


let func1 = (): Point1D => ({ x: 1 });
let func2 = (): Point2D => ({ x: 1, y: 1 });
let func3 = (): Point3D => ({ x: 1, y: 2, z: 3 });

let func: () => Point2D;
func = func1; // error
func = func2; // right
func = func3; // right

函数参数的个数

对于函数的参数,更少的参数是被期望的。

1
2
3
4
5
let handler = (fn: (data: any, msg: string) => void) => {};

handler((data,msg) => console.log(data,msg)); // right
handler((data) => console.log(data,msg)); // right
handler((data,msg,next) => console.log(data,msg,next)); // error

handler 定义时,传入函数需要两个参数,这两个参数是保证的,少传一个参数也不会出现问题。但多一个参数,那么这个多出来的参数就会产生问题。

同样,可选参数和剩余参数也是支持的:

1
2
3
4
5
let handler = (fn: (data: any, msg: string) => void) => {};

handler((data?:any,msg?:any) => console.log(data,msg)); // right
handler((data?:any) => console.log(data)); // right
handler((...args: any[])) => console.log(args)); // right

函数参数的类型

strictFunctionTypes 编译器选项关闭时,函数参数的个数属于 双向协变,否则属于 逆变

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
interface Point1D {
x: number;
}

interface Point2D {
x: number;
y: number;
}

interface Point3D {
x: number;
y: number;
z: number;
}


let func1 = (p: Point1D) => { };
let func2 = (p: Point2D) => { };
let func3 = (p: Point3D) => { };

let func: (p: Point2D) => void;

// 关闭 strictFunctionTypes

func = func1; // right
func = func2; // right
func = func3; // right


// 开启 strictFunctionTypes

func = func1; // right
func = func2; // right
func = func3; // error

双向协变 最初是为了方便事件的传递,而设计出来的。定义时,这个函数提供的是一个泛化的父类型(Event),但调用处理函数可能需要一个特殊化的子类型(如MouseEvent)。这种应用很常见,但是很少出错。

1
2
3
4
5
function listenEvent(eventType: EventType, handler: (n: Event) => void) {
/* ... */
}
// Unsound, but useful and common
listenEvent(EventType.Mouse, (e: MyMouseEvent) => console.log(e.x + "," + e.y));

比较枚举类型

  1. 数字和枚举类型是赋值兼容的。也就是说,数字和枚举类型可以相互赋值。

  2. 用不同的枚举类型定义的变量,被认为是不同的。

比较两个类

  1. 只比较实例部分 仅仅只有实例成员和方法会相比较,构造函数和静态成员不会被检查
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
class People {
age: string
constructor(name: string, age: string) { }
}

class Animal {
age: string
constructor(name: string, age: string, type: string) { }
}

let p: People;
let a: Animal;

a = p; // OK
p = a; // OK

  1. 私有的和受保护的成员必须来自于相同的类
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
class Animal {
protected feet: number;
}
class Cat extends Animal {}

let animal: Animal;
let cat: Cat;

animal = cat; // right
cat = animal; // right

class Size {
protected feet: number;
}

let size: Size;

animal = size; // error
size = animal; // error

比较泛型

  1. 对于泛型类和泛型接口,如果泛型没有被用作成员,那么泛型参数对于类型没有任何影响。
1
2
3
4
5
6
interface Empty<T> { }

let x: Empty<number>;
let y: Empty<string>;

x = y; // right
  1. 对于泛型函数,若泛型参数未被实例化,则作为 any 处理。
1
2
3
4
5
6
7
8
9
let fn1 = function<T>(x: T): T {
// ...
};

let fn2 = function<U>(y: U): U {
// ...
};

fn1 = fn2; // ok

工具类型

适合接口

Partial<T>

用于将一个类型的所有属性设置为可选的。

1
2
3
4
5
6
7
8
9
class UserInfo {
id: number;
name: string;
age: number;
}

let user: Partial<UserInfo> = {
id: 1,
};

Required<T>

用于将一个类型的所有属性设置为必须的。

1
2
3
4
5
6
7
8
9
10
11
class UserInfo {
id?: number;
name?: string;
age?: number;
}

let user: Required<UserInfo> = {
id: 1,
name: "Tom",
age: 18,
};

Readonly<T>

用于将一个类型的所有属性设置为只读的。

1
2
3
4
5
6
7
8
9
10
11
12
13
class UserInfo {
id: number;
name: string;
age: number;
}

let user: Readonly<UserInfo> = {
id: 1,
name: "Tom",
age: 18,
};

user.id = 2; // Cannot assign to 'id' because it is a read-only property.

Record<K,T>

构造一个类型,属性名的类型是 K,属性值的类型为 T。当 K 为字面量类型时,每一个类型必须出现一次。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
class Article {
id: number;
title: string;
content: string;
}

type Recent = "prev" | "next";

type RecentArticles = Record<Recent, Article>;

let articles: RecentArticles = {
prev: {
id: 1,
title: "test",
content: "test",
},
next: {
id: 1,
title: "test",
content: "test",
},
};

Omit<T,K>

从类型 T 中获取所有属性,然后从中剔除属性K

1
2
3
4
5
6
7
8
9
class Article {
id: number;
title: string;
content: string;
}

type ArticleList = Omit<Article, "content">[];

let list: ArticleList = [{ id: 1, title: "test" }];

适合字面量类型和联合类型

Exclude<T,U>

T 中剔除所有可以赋值给 U 的属性。

1
type TargetId = Exclude<0 | 1 | 2 | 3 | 4, 0>; // 1 | 2 | 3 | 4

NotNullable<T>

从类型 T 中剔除 nullundefined,然后构造一个类型。

1
type TargetId = NotNullable<string | null>; // string

其他

ReturnType<T>

用函数的返回值构造一个类型。

1
type T1 = ReturnType<() => number>; // number

InstanceType<T>

用构造函数类型 T 的实例类型构造一个类型。

1
2
3
4
5
6
class Point {
x = 0;
y = 0;
}

type T0 = InstanceType<typeof Point>; // Point

参考资料

  1. TypeScript 入门教程:https://ts.xcatliu.com/
  2. TypeScript 官方文档:https://www.typescriptlang.org/docs
  3. typescript 已经有模块系统了,为什么还需要 namespace:https://www.zhihu.com/question/65676593/answer/242519413
  4. 深入理解 TypeScript: https://jkchao.github.io/typescript-book-chinese/
  • 标题: TypeScript 基础
  • 作者: ObjectKaz
  • 创建于: 2021-08-24 01:26:00
  • 更新于: 2023-05-25 17:18:19
  • 链接: https://www.objectkaz.cn/7a53113f1995.html
  • 版权声明: 本文章采用 CC BY-NC-SA 4.0 进行许可。