Skip to content

TypeScript 笔记

第一章:走进 TS 世界

一、是什么?

TypeScript 简称 TS,是 JavaScript 的超集(JS 有的,TS 都有)。所有的 JS 代码都是 TS 代码。

TypeScript = Type + JavaScript(在 JS 基础上,加了类型支持)。

TypeScript 是微软开发的开源编程语言,可以在任何运行 JavaScript 的地方运行。

1. 为什么有 TS 之类的工具?

JS 在运行代码前,其实不知道函数调用的结果。类似:

javascript
message.toLowerCase();
// message 是否能调用?
// message 是否有一个 toLowerCase 属性?
// 如果有 toLowerCase 是可以被调用的吗?
// 如果可以被调用,会返回什么?

因此,就需要静态类型系统,可以在 JS 运行前先计算出值的类型(包括值的类型,有什么属性和方法),在决定是否执行程序。还可以代码补全(类型检查器有了类型信息,可以在你输入的时候列出可使用的属性、方法)、快速修复。

2. TS 能干什么?

显示类型

typescript
// string, Date就是相应person,date的注解(签名),当不符合定义的类型时就会抛出错误
function greet(person: string, date: Date)

同时,TS 类型系统可以正确推断出一些标识符相应的类型。

类型抹除

TypeScript 代码经过 TSC 转换为 JavaScript 代码。

降级

将高版本的 ECMAScript 语法转为低版本的过程就叫做降级,默认转化为 ES3。

tsc --target es2015 xxx.ts 指定版本。

严格模式

TS 赋予了用户调整代码检查的颗粒度。在 TypeScript 的配置文件 tsconfig.json 中进行配置。

  • "strict": true 严格模式。
  • noImplicitAny 选项设置为 true,表示在 TypeScript 代码中,如果一个变量的类型推断为 any,但没有明确指定类型,那么 TypeScript 编译器将报错。
  • strictNullChecks 选项设置为 true,表示在 TypeScript 代码中,null 和 undefined 值将被严格检查,不能赋值给其他类型的变量,除非这个变量的类型明确包含了 null 或 undefined。

……

3. 快速入门

1)安装

bash
npm install -g typescript

2)创建一个 TS 文件

3)使用 tsc 对 TS 文件进行编译(网页不认 TS 文件,这里要使用编译器将 ts 文件转换成 JS 文件)

​ 执行命令:tsc xxx.ts

​ 运行成功时,会在编译的 TS 文件下产生一个 JS 文件;否则,报相关的错误。错误时仍旧生成 JS 文件。

​ 使用 tsc --noEmitOnError:noEmitOnError 在发生错误时不生成输出。

4)运行编译好的 JS 文件。node xxx.js

简化 TS 运行的步骤

问题:每次都要先生成 JS,然后执行 JS。

简化方式:使用 ts-node 包,直接在 Node.js 中执行 TS 代码。

首先需要安装它。

bash
npm install -g ts-node
# 或者
yarn global add ts-node

使用 ts-node 命令来运行 TypeScript 文件。

bash
ts-node my-script.ts

第二章:类型系统

TypeScript 是 JS 的超集,TS 提供了 JS 的所有功能,并且额外的增加了类型系统。

JS 有类型(比如 number / string 等),但是 JS 不会检查变量的类型是否发生变化。而 TS 会检查。

TypeScript 类型系统的主要优势:可以显示标记出代码中的意外行为,从而降低了发生错误的可能性。

JS 已有类型

原始类型:number boolean string null undefined symbol bigInt

对象类型:函数、object (包括 array)

TS 新增类型

联合类型、自定义类型、接口、元组 (tuple) 、字面量类型、枚举 (enum) 、void、any、unknown、never 等。

类型注解

当使用 const、var 或 let 声明一个变量时,可以选择性的添加一个类型注解,显式指定变量的类型:

typescript
let myName: string = "Alice";

不过大部分时候,这不是必须的。因为 TypeScript 会自动推断类型。

一、基础类型

number、boolean、string、null、undefined、symbol、bigint、object。

typescript
const name: string = 'yxts';
const age: number = 24;
const male: boolean = false;
const undef: undefined = undefined;
const nul: null = null;
const obj: object = { name, age, male };
const bigintVar1: bigint = 9007199254740991n;
const bigintVar2: bigint = BigInt(9007199254740991);
const symbolVar: symbol = Symbol('unique');

注意:

  • null、undefined 在没有开启 strictNullChecks 检查的情况下,会被视作其他类型的子类型,比如 string 类型会被认为包含了 null 与 undefined 类型。

    typescript
    const tmp1: null = null;
    const tmp2: undefined = undefined;
    
    const tmp3: string = null; // 仅在关闭 strictNullChecks 时成立,下同
    const tmp4: string = undefined;

二、特殊类型

void、any、unknown、never。

1. void

如果函数没有返回值,那么,函数返回值类型为:void

typescript
function greet(name: string): void {
  console.log('hello', name)
}

注意:

  • void 是 TS 新增的。

  • 一个函数没返回值,不仅可以标注 void,也可以标注 null (需要在关闭 strictNullChecks 配置的情况下才能成立)、undefined。

    typescript
    const voidVar1: void = undefined;
    
    const voidVar2: void = null; // 需要关闭 strictNullChecks

2. any

TypeScript 有一个特殊的类型 any,当不希望一个值导致类型检查错误的时候,就可以设置为 any。

当一个值是 any 类型的时候,可以获取它的任意属性(也会被转为 any 类型),或者像函数一样调用它,把它赋值给一个任意类型的值,或者把任意类型的值赋值给它,再或者是其他语法正确的操作,都可以:

typescript
let obj: any = { x: 0 };
// 以下任何一行代码都不会引发编译器错误。
// 使用“any”将禁用所有进一步的类型检查,并且假设您比TypeScript更了解环境。
obj.foo();
obj();
obj.bar = 100;
obj = "hello";
const n: number = obj;

当不想写一个长长的类型代码,仅仅想让 TypeScript 知道某段特定的代码是没有问题的,any 类型是很有用的。

3. unknown

unknown 类型和 any 类型有些类似。一个 unknown 类型的变量可以再次赋值为任意其它类型,但一个 unknown 类型的变量只能赋值给另一个 any 与 unknown 类型的变量。

unknown 不能进行任何操作,需要进行类型缩小才行。

4. never

特点

  • never 类型不携带任何的类型信息。
  • never 类型被称为 Bottom Type,是整个类型系统层级中最底层的类型。
  • 和 null、undefined 一样,它是所有类型的子类型,但只有 never 类型的变量能够赋值给另一个 never 类型变量。

使用例子

可以利用 never 类型变量仅能赋值给 never 类型变量的特性,来巧妙地进行分支处理检查:

typescript
declare const strOrNumOrBool: string | number | boolean;

if (typeof strOrNumOrBool === "string") {
  // 一定是字符串!
  strOrNumOrBool.charAt(1);
} else if (typeof strOrNumOrBool === "number") {
  // 一定是 number 类型!
  strOrNumOrBool.toFixed();
} else if (typeof strOrNumOrBool === "boolean") {
  // 一定是 boolean 类型!
  strOrNumOrBool === true;
} else {
  // strOrNumOrBool 变成了 never 类型
  const _exhaustiveCheck: never = strOrNumOrBool;
  throw new Error(`Unknown input type: ${_exhaustiveCheck}`);
}

三、复合类型

1. 数组

声明一个类似于 [1, 2, 3] 的数组类型,需要用到语法 number[]。这个语法可以适用于任何类型。也可能看到这种写法 Array<number>,是一样的。

typescript
let numbers: number[] = [1, 3, 5]
let numbers2: Array<number> = [1, 2, 3]

ReadonlyArray 类型

ReadonlyArray 并不是一个可以用的构造器函数。需要这样使用:

typescript
const roArray: ReadonlyArray<string> = ["red", "green", "blue"];

更简短的写法 readonly Type[]。

typescript
function doStuff(values: readonly string[]) {
  // We can read from 'values'...
  const copy = values.slice();
  console.log(`The first value is ${values[0]}`);

  // ...but we can't mutate 'values'.
  values.push("hello!"); // 属性 'push' 在类型 'readonly string[]' 上不存在。
}

注意:Arrays 和 ReadonlyArray 并不能双向的赋值。也就是可读可写数组可以赋值给只读数组,但只读数组不能赋值给可读可写数组。

2. 元组

声明

元组类型是另一种类型的数组(或者说是数组的特例),它确切地知道包含多少个元素,以及特定索引对应的类型。

typescript
let position1: [number, number] = [29.1, 23.2];

上述代码含义解释:

① 元组类型可以确切地标记出有多少个元素,以及每个元素的类型。

② 该示例中,元素有两个元素,每个元素的类型都是 number。

③ 该元组规定了只能有 2 个元素,再给第三个元素就会报错。

除了长度检查,简单的元组类型跟声明了 length 属性和具体的索引属性的 Array 是一样的。

typescript
interface StringNumberPair {
  // specialized properties
  length: 2;
  0: string;
  1: number;

  // Other 'Array<string | number>' members...
  slice(start?: number, end?: number): Array<string | number>;
}

除了上面的声明方式,TS 还支持具名元组。

typescript
const arr7: [name: string, age: number, male?: boolean] = ['yxts', 599, true];

可选属性

在元组类型中,也可以写一个可选属性,但可选元素必须在最后面,而且也会影响类型的 length。

typescript
type Either2dOr3d = [number, number, number?];

function setCoordinate(coord: Either2dOr3d) {
  const [x, y, z] = coord; // const z: number | undefined

  console.log(`Provided coordinates had ${coord.length} dimensions`); // (property) length: 2 | 3
}

剩余元素语法

Tuples (元组) 也可以使用剩余元素语法,但必须是 array/tuple 类型:

typescript
type StringNumberBooleans = [string, number, ...boolean[]];
type StringBooleansNumber = [string, ...boolean[], number];
type BooleansStringNumber = [...boolean[], string, number];

有剩余元素的元组并不会设置 length,因为它只知道在不同位置上的已知元素信息:

typescript
const a: StringNumberBooleans = ["hello", 1];
const b: StringNumberBooleans = ["beautiful", 2, true];
const c: StringNumberBooleans = ["world", 3, true, false, true, false, true];

console.log(a.length); // (property) length: number

type StringNumberPair = [string, number];
const d: StringNumberPair = ['1', 1];
console.log(d.length); // (property) length: 2

可选元素和剩余元素的存在,使得 TypeScript 可以在参数列表里使用元组,就像这样:

typescript
function readButtonInput(...args: [string, number, ...boolean[]]) {
  const [name, version, ...input] = args;
  // ...
}

基本等同于:

typescript
function readButtonInput(name: string, version: number, ...input: boolean[]) {
  // ...
}

readonly 元组类型

元组类型也是可以设置 readonly。

typescript
function doSomething(pair: readonly [string, number]) {
  pair[0] = "hello!"; // 不能赋值给 '0',因为它是一个只读属性。
}

如果给一个 number[] 进行 const 断言,也会被推断为 readonly 字面量元组类型(readonly [3, 4])。

typescript
let point = [3, 4] as const; // 从 number[] 转变为了只读元组

function distanceFromOrigin([x, y]: [number, number]) {
  return Math.sqrt(x ** 2 + y ** 2);
}

distanceFromOrigin(point);

// 类型 'readonly [3, 4]' 的参数不能赋值给类型 '[number, number]' 的参数。
// 类型 'readonly [3, 4]' 是 'readonly',不能赋值给可变的类型 '[number, number]'。

尽管 distanceFromOrigin 并没有更改传入的元素,但函数希望传入一个可变元组。因为 point 的类型被推断为 readonly [3, 4],它跟 [number, number] 并不兼容,所以 TypeScript 给了一个报错。

3. 枚举

枚举的功能类似于字面量类型 + 联合类型组合的功能,也可以表示一组明确的可选值。

枚举:定义一组命名常量。它描述一个值,该值可以是这些命名常量中的一个。定义好枚举后,可以使用枚举名称作为一种类型。

关键字:enum + 枚举名称 + {枚举的可选值,逗号隔开}。

1)定义

typescript
enum Direciton { Up, Down, Left, Right };

2)使用

typescript
function changeDirection1(direction: Direciton) {
  console.log(direction);
}

解释:

① 使用 enum 关键字定义枚举。

② 约定枚举名称、枚举中的值以大写字母开头。

③ 枚举中的多个值之间通过 ,(逗号)分隔。

④ 定义好枚举后,直接使用枚举名称作为类型注解。

注意:形参 direction 的类型为枚举 Direction,那么,实参的值就应该是枚举 Direction 成员的任意一个。

3)访问枚举成员

类似于 JS 中的对象,直接通过点(.)语法访问枚举的成员。

typescript
changeDirection1(Direciton.Down); // 枚举成员是有值的,默认为:从 0 开始自增的数值。

4)枚举成员的值

问题:我们把枚举成员作为了函数的实参,既然可以把枚举成员作为函数的实参,那么它肯定是有值的,那么它的值是什么呢?

通过将鼠标移入 Direction.Up,可以看到枚举成员 Up 的值为 0。

注意:枚举成员是有值的,默认为:从 0 开始自增的数值。

数字枚举

把枚举成员的值为数字的枚举,称为数字枚举。

typescript
// Up = 10, Down = 11, Left = 12, Right = 13
enum Direciton { Up = 10, Down, Left, Right };

enum Direciton1 { Up = 2, Down = 4, Left = 6, Right = 8 };

字符串枚举

字符串枚举是枚举成员的值是字符串。

typescript
enum Direciton1 { Up = 'UP', Down = 'DOWN', Left = 'LEFT', Right = 'RIGHT' };

注意:字符串枚举没有自增长行为,因此,字符串枚举的每个成员必须有初始值。

5)原理

枚举是 TypeScript 添加的新特性,用于描述一个值可能是多个常量中的一个。不同于大部分的 TypeScript 特性,这并不是一个类型层面的增量,而是会添加到语言和运行时。例如:

typescript
enum Color {
  Red,
  Green,
  Blue
}

会被编译为以下 JavaScript 代码:

javascript
var Color;
(function (Color) {
  Color[Color["Red"] = 0] = "Red";
  Color[Color["Green"] = 1] = "Green";
  Color[Color["Blue"] = 2] = "Blue";
})(Color || (Color = {}));

Color[Color["Red"] = 0] = "Red";:这行代码做了两件事。首先,它在 Color 对象中添加了一个名为 "Red" 的属性,其值为 0。然后,它又在 Color 对象中添加了一个名为 0 的属性,其值为 "Red"。这样,你就可以通过 Color.Red 访问 0,也可以通过 Color[0] 访问 "Red"。

6)建议

一般情况下,推荐使用字面量类型 + 联合类型组合的方式,因为相比枚举,这种方式更加直观、简洁、高效。(其他的类型会在编译为 JS 代码时自动移除。但是,枚举类型会被编译为 JS 代码!),因为枚举会被编译为 js,开销要比前者大。

4. 函数类型

1)指定参数、返回值的类型
[1] 单独指定

参数类型注解

当声明一个函数的时候,可以在每个参数后面添加一个类型注解,声明函数可以接受什么类型的参数。参数类型注解跟在参数名字后面:

typescript
// 参数类型注解
function greet(name: string) {
  console.log("Hello, " + name.toUpperCase() + "!!");
}

当参数有了类型注解的时候,TypeScript 便会检查函数的实参:

typescript
// 如果执行,将会是一个运行时错误
greet(42); // 类型“number”的参数不能赋值给类型“string”的参数

即便对参数没有做类型注解,TypeScript 依然会检查传入参数的数量是否正确。

返回值类型注解

也可以添加返回值的类型注解。返回值的类型注解跟在参数列表后面:

typescript
function getFavoriteNumber(): number {
  return 26;
}

注意:即使没有标注,TypeScript 也会基于它的 return 语句推断函数的返回类型。

[2] 函数类型签名 / 函数类型表达式

解释:当函数作为表达式时,可以通过类似箭头函数形式的语法来为函数添加类型。

注意:这种形式只适用于函数表达式。

typescript
const add2: (num1: number, num2: number) => number = (num1, num2) => {
  return num1 + num2
}

详细解析一下这段代码:

  • const add2:定义了一个名为 add2 的常量。
  • (num1: number, num2: number) => number:这是一个函数类型,表示函数接收两个 number 类型的参数,并返回一个 number 类型的结果。
  • = (num1, num2) => { return num1 + num2 }:这是一个箭头函数,它接收两个参数 num1 和 num2,并返回它们的和。

上面代码的可读性非常差。因此,一般不推荐这么使用,要么直接在函数中进行参数和返回值的类型声明,要么使用类型别名将函数声明抽离出来:

typescript
type GreetFunction = (a: string) => void;

function greeter(fn: GreetFunction) {
  // ...
}

匿名函数有一点不同于函数声明,当 TypeScript 知道一个匿名函数将被怎样调用的时候,匿名函数的参数会被自动的指定类型。这个过程被称为上下文推断(contextual typing),因为正是从函数出现的上下文中推断出了它应该有的类型。

typescript
// 虽然代码中没有类型注解,但是 TypeScript 仍然能够发现潜在的错误
const names = ["Alice", "Bob", "Eve"];

// 函数的上下文类型推断
names.forEach(function (s) {
     console.log(s.toUppercase()); // 类型 'string' 上不存在属性 'toUppercase'。你是不是想要 'toUpperCase'?
});

// 上下文类型推断也适用于箭头函数
names.forEach((s) => {
     console.log(s.toUppercase()); // 类型 'string' 上不存在属性 'toUppercase'。你是不是想要 'toUpperCase'?
});

尽管参数 s 并没有添加类型注解,但 TypeScript 根据 forEach 函数的类型,以及传入的数组的类型,最后推断出了 s 的类型。

2)调用签名

调用签名是一种特殊的类型,它描述了一个函数的参数类型和返回值类型。你可以在一个对象类型中写一个调用签名,这样这个对象类型就同时包含了函数和属性。

例如,以下代码定义了一个带有属性的函数:

typescript
type MyFunctionWithProperties = {
  (x: number, y: number): number; // 这是调用签名
  description: string; // 这是一个属性
};

let func: MyFunctionWithProperties = function(x, y) { return x + y; };
func.description = "A function that adds two numbers";

在这个例子中,MyFunctionWithProperties 是一个带有属性的函数类型,它有一个调用签名 (x: number, y: number): number 和一个属性 description。func 是一个符合这个类型的函数,你可以像调用普通函数一样调用它,也可以访问它的 description 属性。

3)构造签名

JavaScript 函数也可以使用 new 操作符调用,当被调用的时候,TypeScript 会认为这是一个构造函数 (constructors),因为他们会产生一个新对象。你可以写一个构造签名,方法是在调用签名前面加一个 new 关键词:

typescript
type SomeConstructor = {
  new (s: string): SomeObject;
};

function fn(ctor: SomeConstructor) {
  return new ctor("hello");
}

一些对象,比如 Date 对象,可以直接调用,也可以使用 new 操作符调用,而你可以将调用签名和构造签名合并在一起:

typescript
interface CallOrConstruct {
  new (s?: string): Date;
  (n?: number): number;
}
4)可选参数

使用函数实现某个功能时,参数可以传也可以不传。这种情况下,在给函数参数指定类型时,就用到了可选参数。

比如,数组的 slice 方法,可以 slice() 也可以 slice(1) 还可以 slice(1, 3) 。

typescript
function mySlice(num1?: number, num2?: number): void {
  console.log('起始索引', num1, '结束索引', num2)
}

mySlice(1, 2)

可选参数:在可传可不传的参数名称后面添加 ?(问号)。

注意:可选参数只能出现在参数列表的最后,也就是说可选参数后面不能再出现必选参数。

5. 对象类型

class、interface、普通的对象字面量。

JS 中的对象是由属性和方法构成的,而 TS 中对象的类型就是在描述对象的结构(有什么类型的属性和方法)。

1)定义

对象类型可以是匿名的

typescript
// 对象类型
let person: { name: string; age: number; sayHi(): void } = {
  name: 'jack',
  age: 25,
  sayHi() {}
}

解释:

① 直接使用 {} 来描述对象结构。属性采用属性名: 类型的形式;方法采用方法名(): 返回值类型的形式。

② 如果方法有参数,就在方法名后面的小括号中指定参数类型(比如:greet(name: string): void)。

③ 在一行代码中指定对象的多个属性类型时,使用 ;(分号)来分隔。如果一行代码只指定一个属性类型(通过换行来分隔多个属性类型),可以去掉 ;(分号)。也可以使用 , 分开属性,最后一个属性的分隔符加不加都行。

typescript
let person: {
  name: string
  age: number
  sayHi(): void
} = {
  name: 'jack',
  age: 25,
  sayHi() {}
}

④ 每个属性对应的类型是可选的,如果你不指定,默认使用 any 类型。

⑤ 方法的类型也可以使用箭头函数形式(比如:{ sayHi: () => void })

typescript
let person: { name: string; age: number; sayHi: () => void } = {
  name: 'jack',
  age: 25,
  sayHi() {}
}

也可以使用接口进行定义

当一个对象类型被多次使用时,一般会使用接口(interface)来描述对象的类型,达到复用的目的。

typescript
interface Point {
  x: number;
  y: number; // 每一行只有一个属性类型,因此,属性类型后可以没有分号
}

function printCoord(pt: Point) {
  console.log("The coordinate's x value is " + pt.x);
  console.log("The coordinate's y value is " + pt.y);
}

let point1: Point = {
  x: 100,
  y: 100
}

printCoord(point1);

还可以通过类型别名定义。

typescript
type Person = {
  name: string;
  age: number;
};

function greet(person: Person) {
  return "Hello " + person.name;
}

类型别名和接口非常相似,大部分时候,可以任意选择使用。接口的几乎所有特性都可以在 type 中使用。

类型别名和接口的不同

  • 类型别名可以给任意类型起别名,而接口只能声明对象。

  • 类型别名不能多次声明,而接口可以多次声明。

  • 类型别名不能继承,而接口可以继承。

    自 TypeScript 2.7 起,可以通过交叉类型间接“扩展”类型别名,但这不是真正的扩展。

    typescript
    type Point = {
      x: number;
      y: number;
    };
    type Point3D = Point & { z: number };
  • 类型别名不能被类实现,而接口可以。

2)属性修饰符
[1] 对象可选属性

在属性后面加个问号。

typescript
// 对象可选属性
function myAxios(config: {url: string; method?: string }) {
}

myAxios(
  {
    url: 'string'
  }
)

可选属性的语法与函数可选参数的语法一致,都使用 ?(问号)来表示。

在 JavaScript 中,如果你获取一个不存在的属性,你会得到一个 undefined 而不是一个运行时错误。因此,当你获取一个可选属性时,你需要在使用它前,先检查一下是否是 undefined。

typescript
function printName(obj: { first: string; last?: string }) {
  // 错误 - 如果没有提供 'obj.last',可能会崩溃!
  console.log(obj.last.toUpperCase());
  // 对象可能是 'undefined'
  if (obj.last !== undefined) {
    // OK
    console.log(obj.last.toUpperCase());
  }
  
  // 使用现代 JavaScript 语法的安全替代方案
  console.log(obj.last?.toUpperCase());
}

还可以使用对象解构赋值,在形参列表中。

typescript
function paintShape({ shape, xPos = 0, yPos = 0 }: PaintOptions) {
     console.log("x coordinate at", xPos); // (parameter) xPos: number
     console.log("y coordinate at", yPos); // (parameter) yPos: number
     // ...
}

解构语法里没有放置类型注解的方式。

typescript
function draw({ shape: Shape, xPos: number = 100 /*...*/ }) {
     render(shape);
     // Cannot find name 'shape'. Did you mean 'Shape'?
     render(xPos);
     // Cannot find name 'xPos'.
}

在对象解构语法中,shape: Shape 表示的是把 shape 的值赋值给局部变量 Shape。 xPos: number 也是一样,会基于 xPos 创建一个名为 number 的变量。

[2] readonly 属性

一个标记为 readonly 的属性是不能被写入的(不会改变任何运行时的行为,主要是为了在编译时期提供类型检查)。但 readonly 仅仅表明属性本身是不能被重新写入的。

typescript
interface Home {
  readonly resident: { name: string; age: number };
}

function visitForBirthday(home: Home) {
  // 我们可以读取和更新 'home.resident' 的属性。
  console.log(`Happy birthday ${home.resident.name}!`);
  home.resident.age++;
}

function evict(home: Home) {
  // 但是我们不能直接写入 'Home' 上的 'resident' 属性。
  home.resident = {
    // 不能赋值给 'resident',因为它是一个只读属性。
    name: "Victor the Evictor",
    age: 42,
  };
}

TypeScript 在检查两个类型是否兼容的时候,并不会考虑两个类型里的属性是否是 readonly。这就意味着,readonly 的值是可以通过别名修改的。

typescript
interface Person {
  name: string;
  age: number;
}

interface ReadonlyPerson {
  readonly name: string;
  readonly age: number;
}

let writablePerson: Person = {
  name: "Person McPersonface",
  age: 42,
}

// works (正常运行)
let readonlyPerson: ReadonlyPerson = writablePerson;

console.log(readonlyPerson.age); // prints '42'
writablePerson.age++;
console.log(readonlyPerson.age); // prints '43'
[3] 索引签名

不能提前知道一个类型里的所有属性的名字,但是知道这些值的特征。这种情况,就可以用一个索引签名来描述可能的值的类型。

typescript
interface StringArray {
  // 如果尝试在具有 [index: string]: number; 索引签名的接口中添加函数属性,会导致类型错误,因为函数类型 () => number 并不是 number 类型的子类型。
  [index: number]: string;
}

const myArray: StringArray = getStringArray();
const secondItem = myArray[1]; // const secondItem: string

一个索引签名的属性类型必须是 string 或者是 number。虽然 TypeScript 可以同时支持 string 和 number 类型,但数字索引的返回类型一定要是字符索引返回类型的子类型。这是因为当使用一个数字进行索引的时候,JavaScript 实际上把它转成了一个字符串。

typescript
interface Animal {
  name: string;
}

interface Dog extends Animal {
  breed: string;
}

// 错误:使用数字字符串进行索引可能会得到一个完全不同类型的 Animal!
interface NotOkay {
  [x: number]: Animal; // 'number' 索引类型 'Animal' 不能赋值给 'string' 索引类型 'Dog'。
  [x: string]: Dog;
}

尽管字符串索引用来描述字典模式非常的有效,但也会强制要求所有的属性要匹配索引签名的返回类型。

typescript
interface NumberDictionary {
  [index: string]: number;
  
  length: number; // ok
  name: string; // 类型为 'string' 的属性 'name' 不能赋值给 'string' 索引类型 'number'。
}

然而,如果一个索引签名是属性类型的联合,那各种类型的属性就可以接受了:

typescript
interface NumberOrStringDictionary {
  [index: string]: number | string;
  length: number; // ok, length is a number
  name: string; // ok, name is a string
}

最后,也可以设置索引签名为 readonly。

typescript
interface ReadonlyStringArray {
  readonly [index: number]: string;
}

let myArray: ReadonlyStringArray = getReadOnlyStringArray();
myArray[2] = "Mallory"; // 类型 'ReadonlyStringArray' 的索引签名只允许读取。

因为索引签名是 readonly ,所以无法设置 myArray[2] 的值。

3)属性继承

如果两个接口之间有相同的属性或方法,可以将公共的属性或方法抽离出来,通过继承来实现复用。接口也可以继承多个类型。

例如:Point2D 和 Point3D,都有 x、y 两个属性,这样要重复写两次,可以这样做,但是很繁琐。

typescript
interface Point2D {
  x: number
  y: number
}

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

更好的方式,接口继承。继承支持多个。

typescript
interface Point2D {
  x: number
  y: number
}

interface Point3D extends Point2D {
  z: number
}

解释:

① 使用 extends(继承)关键字实现了接口 Point3D 继承 Point2D。

② 继承后,Point3D 就有了 Point2D 的所有属性和方法(此时,Point3D 同时有 x、y、z 三个属性)。

接口继承的使用场景:有 2 个以上的接口有相同的属性,那么就可以将公共的属性或方法进行抽取到一个接口中,然后使用继承实现公共属性或方法的复用。

四、类

TypeScript 完全支持 ES2015 引入的 class 关键字。TypeScript 提供了类型注解和其他语法,允许你表达类与其他类型之间的关系。

1. 定义类

typescript
class Point {}

2. 类成员

typescript
class Point {
  // 字段 / 属性
  x: number;
  // 会阻止在构造函数之外的赋值
  readonly y: number;
  // 设置初始值后,不写类型注解,ts 也会自动推断类型的。z: number
  z = 0;
  y; // 类型注解是可选的,如果没有指定,会隐式的设置为 any
  
  _size = 0;
// ************************************************************************************
  // 类的构造函数:跟函数非常类似,可以使用带类型注解的参数、默认值、重载等。
  /*
    构造函数不能有类型参数。
    构造函数不能有返回类型注解,因为总是返回类实例类型。
  */
  constructor(x: number, y: string);
  constructor(s: string);
  constructor(xs: any = 0, y?: any = 0) {
    // TBD
  }
// ************************************************************************************
  // 方法
  scale(n: number): void {
    this.x *= n;
    this.y *= n;
  }
// ************************************************************************************
  // Getters / Setter 存取器
  /*
    如果 get 存在而 set 不存在,属性会被自动设置为 readonly。
    如果 setter 参数的类型没有指定,它会被推断为 getter 的返回类型。
    getters 和 setters 必须有相同的成员可见性。
    从 TypeScript 4.3 起,存取器在读取和设置的时候可以使用不同的类型。
  */
  // 注意这里返回的是 number 类型
  get size(): number {
    return this._size;
  }
  
  // 注意这里允许传入的是 string | number | boolean 类型
  set size(value: string | number | boolean) {
    let num = Number(value);
    
    // 不允许 NaN、Infinity 等
    if (!Number.isFinite(num)) {
      this._size = 0;
    }
    
    this._size = num;
  }
// ************************************************************************************
  // 索引签名,和对象类型的索引签名是一样的。
  /*
    索引签名类型也是要捕获方法的类型。
  */
  [s: string]: boolean | ((s: string) => boolean);
  check(s: string) {
    return this[s] as boolean;
  }
// ************************************************************************************
  // 静态成员
  /*
    静态成员同样可以使用 public protected 和 private 这些可见性修饰符。
    静态成员也可以被继承。
    静态成员不应该引用类的类型参数 (泛型) 。
    覆写 Function 原型上的属性是不可以的。
  */
  static f = 0;
  static printX() {
    console.log(Point.x);
  }
// ************************************************************************************
  // 类静态块
  /*
    静态块允许写一系列有自己作用域的语句,也可以获取类里的私有字段。
    这意味着我们可以安心的写初始化代码:正常书写语句,无变量泄漏,还可以完全获取类中的属性和方法。
  */
  static {
    try {
      const lastInstances = loadLastInstances();
       Foo.#count += lastInstances.length;
    } catch {}
  }
}

如果有一个基类,需要在使用任何 this. 成员之前,要先在构造函数里调用 super()。忘记调用 super 是 JavaScript 中一个简单的错误,但是 TypeScript 会在需要的时候提醒你。

typescript
class Base {
     k = 4;
}

class Derived extends Base {
     constructor() {
       // 在 ES5 中打印错误的值; 在 ES6 中抛出异常。
       console.log(this.k);
       // 在派生类的构造函数中访问 'this' 之前,必须调用 'super'。
       super();
     }
}

3. 类继承与实现

1)implements

使用 implements 语句检查一个类是否满足一个特定的 interface。如果一个类没有正确的实现它,TypeScript 会报错。

typescript
interface Pingable {
  ping(): void;
}

class Sonar implements Pingable {
  ping() {
    console.log("ping!");
  }
}

class Ball implements Pingable {
  // 'Ball' 类错误地实现了 'Pingable' 接口。
  // 'Ball' 类型缺少 'Pingable' 类型所需的 'ping' 属性。
  pong() {
    console.log("pong!");
  }
}

小贴士:类也可以实现多个接口。

注意事项

implements 语句仅仅检查类是否按照接口类型实现,但它并不会改变类的类型或者方法的类型。一个常见的错误就是以为 implements 语句会改变类的类型 —— 然而实际上它并不会:

implements 关键字在 TypeScript 中主要用于强制类遵循特定的结构,而不是用于改变类的类型或者方法的类型。

typescript
interface Checkable {
  check(name: string): boolean;
}

class NameChecker implements Checkable {
  check(s) {
    // 参数 's' 隐式具有 'any' 类型。
    // 注意这里不会报错,因为是 any。
    return s.toLowercse() === "ok"; // any
  }
}

在这个例子中,可能会以为 s 的类型会被 check 的 name: string 参数影响。实际上并没有,implements 语句并不会影响类的内部是如何检查或者类型推断的。因此,TypeScript 采用的是结构性类型系统。

类似的,实现一个有可选属性的接口,并不会创建这个属性:

typescript
interface A {
  x: number;
  y?: number;
}
class C implements A {
  x = 0;
}
const c = new C();
c.y = 10; // 属性 'y' 不存在于类型 'C' 上。
2)extends

类可以 extend 一个基类。一个派生类有基类所有的属性和方法,还可以定义额外的成员。

一个派生类可以覆写一个基类的字段或属性,也可以使用 super 语法访问基类的方法。

覆写属性

TypeScript 强制要求派生类总是它的基类的子类型,派生类需要遵循着它的基类的实现。具体来说,这个要求确保了以下几点:

① 方法和属性的兼容性:派生类可以覆写基类的方法和属性,但覆写后的方法和属性必须与基类中的原始版本兼容。例如,如果基类有一个返回 number 类型的方法,派生类覆写这个方法时也必须返回 number 类型。(参数类型、数量以及返回类型都需要匹配)

② 扩展而非缩减:派生类可以添加新的方法和属性,但不能删除继承自基类的方法和属性。这确保了派生类的对象至少具备基类的所有功能。

③ 构造函数和方法的参数:派生类中覆写的方法可以有与基类中相同的参数,或者有更宽松的参数(例如,参数是基类参数类型的子类型),但不能有更严格的参数类型。这是为了确保在使用基类类型引用派生类对象时,任何对基类方法的调用都能在派生类中正确工作。

下面是一个不合法的覆写属性。

typescript
class Base {
  greet() {
    console.log("Hello, world!");
  }
}

class Derived extends Base {
  // 使这个参数成为必需的
  greet(name: string) {
	// 类型 'Derived' 中的属性 'greet' 不能分配给基类型 'Base' 中的同名属性。
    // 类型 '(name: string) => void' 不能分配给类型 '() => void'。
    console.log(`Hello, ${name.toUpperCase()}`);
  }
}

即便我们忽视错误编译代码,这个例子也会运行错误。

typescript
const b: Base = new Derived();
// 因为 "name" 将是未定义的,所以会崩溃。
b.greet();

解决方案是使用可选参数或者方法重载。

typescript
class Base {
  greet() {
    console.log("Hello, world!");
  }
}

class Derived extends Base {
  // 方法一: 使用可选参数
  greet(name?: string) {
    if (name === undefined) {
      super.greet();
    } else {
      console.log(`Hello, ${name.toUpperCase()}`);
    }
  }

  // 方法二: 方法重载签名
  greet(): void;
  greet(name: string): void;
}

const d = new Derived();
d.greet();
d.greet("reader");

初始化顺序

类初始化的顺序,就像在 JavaScript 中定义的那样:

  • 基类字段初始化
  • 基类构造函数运行
  • 派生类字段初始化
  • 派生类构造函数运行
typescript
class Base {
  name = "base";
  constructor() {
    console.log("My name is " + this.name);
  }
}

class Derived extends Base {
  name = "derived";
}

// 打印 "base",而不是 "derived"。
const d = new Derived();

继承内置类型

问题

在 ES2015(也称为 ES6)中,当在子类的构造函数中调用 super() 时,它会调用父类的构造函数,并返回父类构造函数的结果。如果父类构造函数返回了一个对象,那么这个对象会替换子类构造函数中的 this。

typescript
class Parent {
     constructor() {
       // 显式返回一个对象
       return { name: "Parent Object" };
     }
}

class Child extends Parent {
     yxts: string
     constructor() {
       super(); // 调用父类的构造函数
       this.yxts = '666'
       // 通常情况下,这里的 this 会指向 Child 的实例
       // 但由于父类返回了一个对象,这个 this 会被父类返回的对象替换
       console.log(this); // 输出的将是 { name: "Parent Object", yxts: '666' },而不是 Child 的实例
     }
}

const child = new Child();
// 由于父类构造函数返回了一个对象,这个对象替换了子类构造函数中的 this
// 因此,child 实际上是父类构造函数返回的对象,而不是 Child 类的实例
console.log(child); // 输出结果 { name: "Parent Object", yxts: '666' }

在 ES2015 中,内置对象(如 Error,Array 等)的构造函数会使用 new.target 来调整原型链。new.target 是一个在构造函数中可用的元属性,它引用的是正在构造的类。这使得内置对象的子类可以正确地继承父类的行为。

javascript
function Test() {
      console.log(new.target);
}
   
Test(); // 输出:undefined
new Test(); // 输出:[Function: Test]

但是,在 ES5 中,没有 new.target 这样的特性。因此,当使用 ES5 或者将 ES6 代码转换为 ES5 代码的时,内置对象的子类可能无法正确地继承原始类的行为。举例:

typescript
class MsgError extends Error {
       constructor(m: string) {
           super(m);
       }
       sayHello() {
           return "hello " + this.message;
       }
   }

1️⃣ 对象的方法可能是 undefined,所以调用 sayHello 会导致错误。

2️⃣ instanceof 失效,(new MsgError()) instanceof MsgError 会返回 false。

这是因为 Error 构造函数内部大致是这样工作的:

typescript
function Error(message) {
       // 创建一个新对象
       var instance = new Object();
       // 设置消息等属性
       instance.message = message;
       // 直接设置原型为 Error.prototype,忽略继承链
       Object.setPrototypeOf(instance, Error.prototype);
   
       // 返回这个新对象,而不是使用 this
       return instance;
   }

解决

手动的在 super(...) 调用后调整原型:

typescript
class MsgError extends Error {
      constructor(m: string) {
        super(m);
    
        // 显式地设置原型
        Object.setPrototypeOf(this, MsgError.prototype);
      }
    
      sayHello() {
        return "hello " + this.message;
      }
}

4. 成员可见性

可以使用 TypeScript 控制某个方法或者属性是否对类以外的代码可见。

  • public:这是默认的可见性。公共成员在任何地方都可以访问。

  • protected:受保护的成员可以在类的内部和子类内部中访问,但不能使用类的实例调用。

    受保护成员的公开

    typescript
    class Base {
      protected m = 10;
    }
    class Derived extends Base {
      // 没有修饰符,所以默认为 '公开'。
      m = 15;
    }
    const d = new Derived();
    console.log(d.m); // OK

    交叉等级受保护成员访问

    typescript
    class Base {
      protected x: number = 1;
    }
    class Derived1 extends Base {
      protected x: number = 5;
    }
    class Derived2 extends Base {
      f1(other: Derived2) {
        other.x = 10;
      }
      f2(other: Base) {
        other.x = 10; // 属性 'x' 是受保护的,只能通过 'Derived2' 类的实例访问。这是 'Base' 类的一个实例。
      }
    }
  • private:私有成员只能在类的内部访问。这意味着,不能从类的实例或子类中访问私有成员。

    交叉实例私有成员的获取

    typescript
    class A {
      private x = 10;
      
      public sameAs(other: A) {
        // 没问题
        return other.x === this.x;
      }
    }

5. this

1)this 参数

在 TypeScript 方法或者函数的定义中,第一个参数且名字为 this 有特殊的含义。该参数会在编译的时候被抹除:

typescript
// TypeScript input with 'this' parameter
function fn(this: SomeType, x: number) {
  /* ... */
}
typescript
// JavaScript output
function fn(x) {
  /* ... */
}

TypeScript 会检查一个有 this 参数的函数在调用时是否有一个正确的上下文。可以给方法定义添加一个 this 参数,静态强制方法被正确调用:

typescript
class MyClass {
  name = "MyClass";
  getName(this: MyClass) {
    return this.name;
  }
}
const c = new MyClass();
// OK
c.getName();

// 错误,会导致崩溃
const g = c.getName;
console.log(g()); // 类型 'void' 的 'this' 上下文不能赋值给方法的 'MyClass' 类型的 'this'

这个方法也有一些注意点,正好跟箭头函数相反:

  • JavaScript 调用者依然可能在没有意识到它的时候错误使用类方法。

    即使在 TypeScript 中通过为方法定义添加一个特殊的 this 参数来静态强制正确的 this 上下文,一旦 TypeScript 代码被编译成 JavaScript,这种静态类型检查就不存在了。因此,JavaScript 代码的执行者可能会在不了解或不注意到正确的 this 上下文的情况下错误地使用这些方法。

  • 每个类只有一个函数,而不是每一个类实例一个函数。

  • 基类方法定义依然可以通过 super 调用。

2)this 类型

在类中,有一个特殊的名为 this 的类型,会动态的引用当前类的类型:

typescript
class Box {
  contents: string = "";
  set(value: string) { // (method) Box.set(value: string): this
    this.contents = value;
    return this;
  }
}

这里,TypeScript 推断 set 的返回类型为 this 而不是 Box。写一个 Box 的子类:

typescript
class ClearableBox extends Box {
  clear() {
    this.contents = "";
  }
}

const a = new ClearableBox();
const b = a.set("hello"); // const b: ClearableBo

也可以在参数类型注解中使用 this:

typescript
class Box {
  content: string = "";
  sameAs(other: this) {
    return other.content === this.content;
  }
}

不同于写 other: Box,如果你有一个派生类,它的 sameAs 方法只接受来自同一个派生类的实例。

typescript
class Box {
  content: string = "";
  sameAs(other: this) {
    return other.content === this.content;
  }
}

class DerivedBox extends Box {
  otherContent: string = "?";
}

const base = new Box();
const derived = new DerivedBox();
derived.sameAs(base);
// 类型 'Box' 的参数不能赋值给类型 'DerivedBox' 的参数。
// 类型 'Box' 中缺少 'DerivedBox' 类型所需的 'otherContent' 属性。
3)类型谓词 this
[1] 是什么

在 TypeScript 中,this is Type 是一种特殊的类型谓词,通常用在方法的返回类型位置。这种类型谓词表示方法的调用者(this)在方法返回后可以被视为 Type 类型。

这种类型谓词在运行时并没有实际效果,但在编译时,TypeScript 会根据这个类型谓词来进行类型检查和推断。

例如,可以在一个类的方法中使用 this is Type 来确保某个条件满足后,this 可以被视为 Type 类型:

typescript
class MyClass {
      isType(): this is Type {
        // 在这里检查 this 是否可以被视为 Type 类型
        // 如果可以,返回 true
        // 否则,返回 false
      }
}

如果 isType 方法返回 true,那么在调用 isType 方法后,this 可以被视为 Type 类型。这样,就可以在类型安全的情况下访问 Type 类型的属性和方法。

typescript
class FileSystemObject {
  isFile(): this is FileRep {
    return this instanceof FileRep;
  }
  isDirectory(): this is Directory {
    return this instanceof Directory;
  }
  isNetworked(): this is Networked & this {
    return this.networked;
  }
  constructor(public path: string, private networked: boolean) {}
}

class FileRep extends FileSystemObject {
  constructor(path: string, public content: string) {
    super(path, false);
  }
}

class Directory extends FileSystemObject {
  children: FileSystemObject[];
}

interface Networked {
  host: string;
}

const fso: FileSystemObject = new FileRep("foo/bar.txt", "foo");

if (fso.isFile()) {
  fso.content; // const fso: FileRep
} else if (fso.isDirectory()) {
  fso.children; // const fso: Directory
} else if (fso.isNetworked()) {
  fso.host; // const fso: Networked & FileSystemObject
}
[2] 应用例子

可以在类和接口的方法返回的位置,使用 this is Type。当搭配使用类型收窄 (举个例子,if 语句) ,目标对象的类型会被收窄为更具体的 Type。

例子一:安全访问可选值

一个常见的基于 this 的类型保护的使用例子,会对一个特定的字段进行懒校验(lazy validation)。举个例子,在这个例子中,当 hasValue 被验证为 true 时,会从类型中移除 undefined。

typescript
class Box<T> {
  value?: T;

  hasValue(): this is { value: T } {
    return this.value !== undefined;
  }
}

const box = new Box();
box.value = "Gameboy";

box.value; // (property) Box<unknown>.value?: unknown

// 常见的不太优雅的写法
if (box.value !== undefined) {
  doSomething(box.value);
}

// 使用类型保护的写法
if (box.hasValue()) {
  box.value; // (property) value: unknown
}

例子二:API 响应处理

typescript
class ApiResponse<T> {
  data?: T;
  error?: string;

  isSuccess(): this is { data: T; error: undefined } {
    return this.data !== undefined;
  }

  isError(): this is error: string } {
    return this.error !== undefined;
  }
}

async function handleUserData() {
  const response = new ApiResponse<User>();
  // ... 获取数据 ...

  if (response.isSuccess()) {
    // TypeScript 知道这里可以安全访问 response.data
    updateUI(response.data);
  } else if (response.isError()) {
    // TypeScript 知道这里可以安全访问 response.error
    showError(response.error);
  }
}

例子三:状态管理

typescript
class FormState<T> {
  private data?: T;
  private validationErrors?: string[];

  isValid(): this is { data: T } {
    return this.data !== undefined && !this.validationErrors?.length;
  }

  submit() {
    if (this.isValid()) {
      // 安全地访问 data,TypeScript 知道这里 data 一定存在
      api.send(this.data);
    }
  }
}
typescript
interface UserData {
  name: string;
  email: string;
}

class UserForm extends FormState<UserData> {
  submit() {
    if (this.isValid()) {
      // TypeScript 知道这里的 this.data 一定存在
      // 且是 UserData 类型
      console.log(this.data.name);  // 安全
      console.log(this.data.email); // 安全
      
      api.send(this.data);  // 安全
    } else {
      // 在这个分支,TypeScript 知道 data 可能不存在
      // console.log(this.data.name);  // 类型错误!
    }
  }
}

例子四:懒加载资源例子

typescript
// 定义一个泛型类,T 代表要加载的资源类型
class LazyResource<T> {
  // resource 是可选的,初始为 undefined
  private resource?: T;

  // 类型保护方法:当返回 true 时,TypeScript 知道 resource 一定存在
  isLoaded(): this is { resource: T } {
    return this.resource !== undefined;
  }

  // 异步加载方法
  async load() {
    // 如果资源还没加载,则加载它
    if (!this.isLoaded()) {
      this.resource = await fetchResource();
    }
    // 返回已加载的资源
    return this.resource;
  }

  // 使用资源的方法
  use() {
    if (this.isLoaded()) {
      // 由于类型保护,TypeScript 知道这里 resource 一定存在
      // 可以安全地调用 resource 上的方法
      return this.resource.doSomething();
    }
    // 如果资源未加载,抛出错误
    throw new Error('Resource not loaded');
  }
}
jsx
// 定义一个图片资源类型
interface ImageResource {
  url: string;
  width: number;
  height: number;
  doSomething(): void;
}

// 创建一个懒加载的图片资源
const lazyImage = new LazyResource<ImageResource>();

// 使用场景
async function handleImage() {
  try {
    // 首次使用时加载资源
    await lazyImage.load();
    
    // 使用已加载的资源
    if (lazyImage.isLoaded()) {
      lazyImage.use();
    }
  } catch (error) {
    console.error('Failed to load image');
  }
}

// 或者在 React 组件中使用
function ImageComponent() {
  useEffect(() => {
    lazyImage.load().catch(console.error);
  }, []);

  return (
    <div>
      {lazyImage.isLoaded() ? (
        <img src={lazyImage.resource.url} />
      ) : (
        <LoadingSpinner />
      )}
    </div>
  );
}

6. 参数属性

TypeScript 提供了特殊的语法,可以把一个构造函数参数转成一个同名同值的类属性。这些就被称为参数属性(parameter properties)。

可以通过在构造函数参数前添加一个可见性修饰符 public、private、protected 或者 readonly 来创建参数属性,最后这些类属性字段也会得到这些修饰符:

typescript
class Params {
  constructor(
    public readonly x: number,
    protected y: number,
    private z: number
  ) {
    // 无需主体
  }
}
const a = new Params(1, 2, 3);
console.log(a.x); // (property) Params.x: number

console.log(a.z); // 属性 'z' 是私有的,只能在 'Params' 类内部访问

7. 类表达式

typescript
const someClass = class<Type> {
  content: Type;
  constructor(value: Type) {
    this.content = value;
  }
};

const m = new someClass("Hello, world"); // const m: someClass<string>

8. 抽象类和成员

TypeScript 中,类、方法、字段都可以是抽象的(abstract)。

抽象方法或者抽象字段是不提供实现的。这些成员必须存在在一个抽象类中,这个抽象类也不能直接被实例化。

抽象类的作用是作为子类的基类,让子类实现所有的抽象成员。当一个类没有任何抽象成员,他就会被认为是具体的。

例子:

typescript
abstract class Base {
  abstract getName(): string;
  
  printName() {
    console.log("Hello, " + this.getName());
  }
}

const b = new Base(); // 无法创建抽象类的实例

不能使用 new 实例 Base,因为它是抽象类。需要写一个派生类,并且实现抽象成员。

typescript
class Derived extends Base {
  getName() {
    return "world";
  }
}

const d = new Derived();
d.printName();

注意,如果忘记实现基类的抽象成员,会得到一个报错:

typescript
class Derived extends Base {
  // 非抽象类 'Derived' 没有实现从 'Base' 类继承的抽象成员 'getName'。
  // 忘记做任何事情。
}

抽象构造签名

有的时候,希望接受传入可以继承一些抽象类产生一个类的实例的类构造函数。

举个例子,也许会写这样的代码:

typescript
function greet(ctor: typeof Base) {
  const instance = new ctor();
  // 无法创建抽象类的实例
  instance.printName();
}

TypeScript 会报错,告诉你正在尝试实例化一个抽象类。毕竟,根据 greet 的定义,这段代码应该是合法的:

typescript
// 糟糕!
greet(Base);

但如果你写一个函数接受传入一个构造签名:

typescript
function greet(ctor: new () => Base) {
  const instance = new ctor();
  instance.printName();
}
greet(Derived);

// 类型 'typeof Base' 的参数不能赋值给类型 'new () => Base' 的参数。
// 不能将抽象构造函数类型赋值给非抽象构造函数类型。
greet(Base);

现在 TypeScript 会正确的告诉你,哪一个类构造函数可以被调用,Derived 可以,因为它是具体的,而 Base 是不能的。

9. 类之间的关系

大部分时候,TypeScript 的类跟其他类型一样,会被结构性比较。

举个例子,这两个类可以用于替代彼此,因为它们结构是相等的:

typescript
class Point1 {
  x = 0;
  y = 0;
}

class Point2 {
  x = 0;
  y = 0;
}

// OK
const p: Point1 = new Point2();

类似的还有,类的子类型之间可以建立关系,即使没有明显的继承:

typescript
class Person {
  name: string;
  age: number;
}

class Employee {
  name: string;
  age: number;
  salary: number;
}

// OK
const p: Person = new Employee();

这听起来有些简单,但还有一些例子可以看出奇怪的地方。

空类没有任何成员。在一个结构化类型系统中,没有成员的类型通常是任何其他类型的父类型。所以如果你写一个空类,任何东西都可以用来替换它:

typescript
class Empty {}

function fn(x: Empty) {
  // 对 'x' 无法进行任何操作,所以我不会这么做
}

// All OK!
fn(window);
fn({});
fn(fn);

五、高级类型

1. 联合类型

联合类型(Union Types)是一种复合类型,表示一个值可以是几种类型之一。

使用管道符(|)分隔每个类型。这意味着一个值可以是 type1 或 type2 或 type3 等。

typescript
let value: number | string;

value = 123; // OK
value = '123'; // OK
value = true; // 错误:类型“boolean”不可分配给类型“number | string”

在使用中注意括号问题。例如:

添加小括号,表示首先 arr 是数组,其次数组元素是 number 或者是 string。

typescript
let arr: (number | string )[] = [1, 'a', 4, 'b', 4]

另外一种情况,不添加括号,表示 arr 既可以是数字 number,也可以是字符串数组。

typescript
let arr: number | string[] = 12

2. 类型别名

1)声明

类型别名(Type Aliases)是一种创建新名称的方式,可以为任何类型创建一个新的名字,然后在代码中使用这个新的名字来代表原来的类型。

类型别名是通过 type 关键字来定义的。

typescript
type StringOrNumber = string | number;

let value: StringOrNumber;
value = 'hello'; // OK
value = 123; // OK
2)工具类型

是什么

在类型别名中,类型别名可以这么声明自己能够接受的泛型(我称之为泛型坑位)。一旦接受了泛型,我们就叫它工具类型:

typescript
type Factory<T> = T | number | string;

当然,我们一般不会直接使用工具类型来做类型标注,而是再度声明一个新的类型别名:

typescript
type FactoryWithBool = Factory<boolean>;

const foo: FactoryWithBool = true;

有实际意义的工具类型

typescript
type MaybeNull<T> = T | null;

function process(input: MaybeNull<{ handler: () => {} }>) {
  input?.handler();
}

// -------------------------------------------------------------
type MaybeArray<T> = T | T[];

function ensureArray<T>(input: MaybeArray<T>): T[] {
  return Array.isArray(input) ? input : [input];
}

3. 字面量类型

是什么

除了常见的类型 string 和 number,也可以将类型声明为更具体的数字或者字符串。

众所周知,在 JavaScript 中,有多种方式可以声明变量。比如 var 和 let,这种方式声明的变量后续可以被修改,还有 const,这种方式声明的变量则不能被修改,这就会影响 TypeScript 为字面量创建类型。

typescript
let changingString = "Hello World";
changingString = "Olá Mundo";
// 因为“changinString”可以表示任何可能的字符串,所以TypeScript在类型系统中就是这样描述它的
// let changingString: string
typescript
const constantString = "Hello World";
// 由于“constantString”只能表示1个可能的字符串,因此它具有文字类型表示
// const constantString: "Hello World"

字面量类型本身并没有什么太大用:

typescript
let x: "hello" = "hello";
// OK
x = "hello";
// ...
x = "howdy"; // 类型“howdy”不可分配给类型“hello”。

如果结合联合类型,就显得有用多了。举个例子,当函数只能传入一些固定的字符串时:

typescript
function printText(s: string, alignment: "left" | "right" | "center") {
  // ...
}
printText("Hello, world", "left");
printText("G'day, mate", "centre"); // 类型为“center”的参数不可分配给类型为“left”|“right”|“center””的参数

数字字面量类型也是一样的:

typescript
function compare(a: string, b: string): -1 | 0 | 1 {
  return a === b ? 0 : a > b ? 1 : -1;
}

当然了,也可以跟非字面量类型联合:

typescript
interface Options {
  width: number;
}
function configure(x: Options | "auto") {
  // ...
}
configure({ width: 100 });
configure("auto");
configure("automatic"); // 类型为“automatic”的参数不可分配给类型为“Options|”auto“”的参数

还有一种字面量类型,布尔字面量。因为只有两种布尔字面量类型,true 和 false,类型 boolean 实际上就是联合类型 true | false 的别名。

字面量推断

typescript
declare function handleRequest(url: string, method: "GET" | "POST"): void;

const req = { url: "https://example.com", method: "GET" };
handleRequest(req.url, req.method); // 类型 'string' 的参数不能赋值给类型 '"GET" | "POST"' 的参数。

在上面这个例子里,req.method 被推断为 string,而不是 "GET",因为在创建 req 和调用 handleRequest 函数之间,可能还有其他的代码,或许会将 req.method 赋值一个新字符串。所以 TypeScript 就报错了。

有两种方式可以解决。

  • 添加一个类型断言改变推断结果:

    typescript
    // Change 1:
    const req = { url: "https://example.com", method: "GET" as "GET" };
    // Change 2
    handleRequest(req.url, req.method as "GET");

    修改 1 表示“我有意让 req.method 的类型为字面量类型 "GET",这会阻止未来可能赋值为 "GUESS" 等字段。修改 2 表示“我知道 req.method 的值是 "GET"。

  • 也可以使用 as const 把整个对象转为一个字面量类型:

    typescript
    /*
      type const = {
        readonly url: "https://example.com";
        readonly method: "GET";
      }
    */
    const req = { url: "https://example.com", method: "GET" } as const;
    handleRequest(req.url, req.method);

    as const 效果跟 const 类似,但是对类型系统而言,它可以确保所有的属性都被赋予一个字面量类型,而不是一个更通用的类型比如 string 或者 number。

4. 交叉类型

1)使用
[1] 对象类型交叉

TypeScript 也提供了名为交叉类型(Intersection types)的方法,用于合并已经存在的对象类型。

交叉类型的定义需要用到 & 操作符:

typescript
interface Colorful {
  color: string;
}
interface Circle {
  radius: number;
}

type ColorfulCircle = Colorful & Circle;

这里,我们连结 Colorful 和 Circle 产生了一个新的类型,新类型拥有 Colorful 和 Circle 的所有成员。

typescript
function draw(circle: Colorful & Circle) {
  console.log(`Color was ${circle.color}`);
  console.log(`Radius was ${circle.radius}`);
}

// okay
draw({ color: "blue", radius: 42 });

// oops
draw({ color: "red", raidus: 42 });
// “类型为 '{ color: string; raidus: number; }' 的参数不能赋值给 'Colorful & Circle' 类型的参数。对象字面量只能指定已知的属性,但 'raidus' 在 'Colorful & Circle' 类型中不存在。你是想写 'radius' 吗?”
[2] 原始类型交叉
typescript
type StrAndNum = string & number; // never
[3] 联合类型交叉

如果是两个联合类型组成的交叉类型呢?其实还是类似的思路,既然只需要实现一个联合类型成员就能认为是实现了这个联合类型,那么各实现两边联合类型中的一个就行了,也就是两边联合类型的交集:

typescript
type UnionIntersection1 = (1 | 2 | 3) & (1 | 2); // 1 | 2
type UnionIntersection2 = (string | number | symbol) & string; // string
[4] 原始类型与对象类型交叉
2)接口继承与交叉类型对比

这两种方式在合并类型上看起来很相似,但实际上还是有很大的不同。最原则性的不同就是在于冲突怎么处理,这也是你决定选择哪种方式的主要原因。

typescript
interface Colorful {
  color: string;
}

interface ColorfulSub extends Colorful {
  color: number
}

// 接口 'ColorfulSub' 不正确地扩展了接口 'Colorful'。属性 'color' 的类型不兼容。类型 'number' 不能赋值给类型 'string'。

使用继承的方式,如果重写类型会导致编译错误,但交叉类型不会:

typescript
interface Colorful {
  color: string;
}

type ColorfulSub = Colorful & {
  color: number
}

虽然不会报错,但是 color 属性的类型是什么呢?答案是 never,取得是 string 和 number 的交集。

交叉类型通常会与映射类型结合。先举个简单的例子。

typescript
type bar = string & ("name" | "age" | 123 | true); // "name" | "age"

/*
  string & ("name" | "age" | 123 | true)
      = (string & "name") | (string & "age") | (string & 123) | (string & true)
      = "name" | "age" | never | never
      = "name" | "age"
      
  注意:一定要加 () , 否则结果是:
    string & "name" | "age" | 123 | true
        = (string & "name") | "age" | 123 | true
        = "name" | "age" | 123 | true
    因为交叉类型 `&` 的优先级高于联合类型 `|`
*/

相信你能看懂下面 ${string & keyof Type} 代码了。

typescript
type PropEventSource<Type> = {
  on(eventName: `${string & keyof Type}Changed`, callback: (newValue: any) => void): void;
};

六、泛型

软件工程的一个重要部分就是构建组件,组件不仅需要有定义良好和一致的 API,也需要是可复用的(reusable)。好的组件不仅能够兼容今天的数据类型,也能适用于未来可能出现的数据类型,这在构建大型软件系统时会给你最大的灵活度。

在比如 C# 和 Java 语言中,用来创建可复用组件的工具,我们称之为泛型(generics)。利用泛型,我们可以创建一个支持众多类型的组件,这让用户可以使用自己的类型消费(consume)这些组件。

1. 泛型定义

1)泛型函数

需要一种可以捕获参数类型的方式,然后再用它表示返回值的类型。这里我们用了一个类型变量(type variable),一种用在类型而非值上的特殊的变量。

typescript
function identity<Type>(arg: Type): Type {
  return arg;
}

let myIdentity: <Type>(arg: Type) => Type = identity;

有两种方式可以调用它。第一种方式是传入所有的参数,包括类型参数:

typescript
let output = identity<string>("myString"); // let output: string

在这里,我们使用 <> 而不是 ()包裹了参数,并明确的设置 Type 为 string 作为函数调用的一个参数。

第二种方式可能更常见一些,这里我们使用了类型参数推断(type argument inference)(部分中文文档会翻译为“类型推论”),我们希望编译器能基于我们传入的参数自动推断和设置 Type 的值。

typescript
let output = identity("myString"); // let output: string

注意:这次我们并没有用 <> 明确的传入类型,当编译器看到 myString 这个值,就会自动设置 Type 为它的类型(即 string)。

2)泛型接口

对象类型的调用签名的形式

typescript
function identity<Type>(arg: Type): Type {
  return arg;
}

let myIdentity: { <Type>(arg: Type): Type } = identity;

{ <Type>(arg: Type): Type } 是一个对象类型,它有一个调用签名。这个调用签名是一个泛型函数,它接受一个参数 arg,参数的类型是 Type,并返回一个相同类型的值。Type 是一个类型变量,它代表任何类型。

这意味着你可以将任何符合这个调用签名的函数赋值给 myIdentity 变量。例如:

typescript
myIdentity = function<Type>(arg: Type): Type {
  return arg;
}

在这个例子中,我们定义了一个泛型函数,并将这个函数赋值给 myIdentity。这个函数接受一个参数,并返回一个相同类型的值,所以它符合 { <Type>(arg: Type): Type } 的定义。

然后,你可以通过 myIdentity 变量来调用这个函数。例如:

typescript
let number = myIdentity(123);
let string = myIdentity('hello');

在这个例子中,myIdentity(123) 调用了我们之前定义的函数,并传入了一个 number 类型的参数 123。myIdentity('hello') 调用了我们之前定义的函数,并传入了一个 string 类型的参数 'hello'。

泛型接口

让我们使用上个例子中的对象字面量,然后把它的代码移动到接口里:

typescript
interface GenericIdentityFn {
  <Type>(arg: Type): Type;
}

function identity<Type>(arg: Type): Type {
  return arg;
}

let myIdentity: GenericIdentityFn = identity;

// 使用
interface Person {
  name: string;
  age: number;
}

let person: Person = { name: "Bob", age: 25 };
let result6 = myIdentity<Person>(person);
console.log(result6);  // 输出: { name: "Bob", age: 25 }

有的时候,我们会希望将泛型参数作为整个接口的参数,这可以让我们清楚的知道传入的是什么参数 (举个例子:Dictionary<string> 而不是 Dictionary)。而且接口里其他的成员也可以看到。

typescript
// 泛型接口描述了一个函数
interface GenericIdentityFn<Type> {
  (arg: Type): Type;
}

function identity<Type>(arg: Type): Type {
  return arg;
}

let myIdentity: GenericIdentityFn<number> = identity;

myIdentity(42); // 返回 42
myIdentity("Hello"); // 错误:参数类型不匹配
3)泛型类

泛型类写法上类似于泛型接口。在类名后面,使用尖括号中 <> 包裹住类型参数列表:

typescript
class GenericNumber<NumType> {
  zeroValue: NumType;
  add: (x: NumType, y: NumType) => NumType;
}

let myGenericNumber = new GenericNumber<number>();
myGenericNumber.zeroValue = 0;
myGenericNumber.add = function (x, y) {
  return x + y;
};

2. 泛型约束

基本使用

typescript
interface Lengthwise {
  length: number;
}

function loggingIdentity<Type extends Lengthwise>(arg: Type): Type {
  console.log(arg.length); // 现在我们知道它有一个 .length 属性,所以不再报错。
  return arg;
}

现在这个泛型函数被约束了,它不再适用于所有类型:

typescript
loggingIdentity(3); // 类型 'number' 的参数不能赋值给类型 'Lengthwise' 的参数。

需要传入符合约束条件的值:

typescript
loggingIdentity({ length: 10, value: 3 });

在泛型约束中使用类型参数

希望获取一个对象给定属性名的值。为此,需要确保不会获取 obj 上不存在的属性。所以就要在两个类型之间建立一个约束:

typescript
function getProperty<Type, Key extends keyof Type>(obj: Type, key: Key) {
  return obj[key];
}

let x = { a: 1, b: 2, c: 3, d: 4 };

getProperty(x, "a");
getProperty(x, "m"); // 类型为 'm' 的参数不能赋值给类型为 'a' | 'b' | 'c' | 'd' 的参数。

在泛型中使用类类型

typescript
/* ------------------------------- 构造函数不支持带参数 ------------------------------- */
function create<Type>(c: { new (): Type }): Type {
  return new c();
}

// 定义一个简单的类
class User {
  name: string = "default";
  sayHello() {
    console.log(`Hello, I'm ${this.name}`);
  }
}

// 使用 create 函数创建实例
const user = create(User);
user.sayHello(); // 输出: Hello, I'm default

/* ------------------------------- 构造函数支持带参数 ------------------------------- */
function createWithArgs<Type, Args extends any[]>(
  c: { new (...args: Args): Type }, 
  ...args: Args
): Type {
  return new c(...args);
}

// 使用示例
class WithParams {
  constructor(public name: string) {}
}

const instance = createWithArgs(WithParams, "test");

3. 泛型工具类型

Typescript 提供了一些工具类型来辅助进行常见的类型转换,这些类型全局可用。

1)Partial<Type>

将一个类型 Type 中的所有属性都变为可选的。

typescript
type MyType = {
  a: number;
  b: string;
};

使用 Partial<MyType>,会得到一个新的类型:

typescript
type PartialMyType = Partial<MyType>; // { a?: number; b?: string; }

Partial<Type> 源码:

typescript
type Partial<T> = {
  [P in keyof T]?: T[P];
};
2)Required<Type>

将一个类型 Type 中的所有属性都变为必需的。

typescript
type MyType = {
  a?: number;
  b?: string;
};

使用 Required<Type>,会得到一个新的类型:

typescript
type RequiredMyType = Required<MyType>; // { a: number; b: string; }

Required<Type> 源码:

typescript
type Required<T> = {
  [P in keyof T]-?: T[P];
};
3)Readonly<Type>

将一个类型 Type 中的所有属性都变为只读的。

typescript
type MyType = {
  a: number;
  b: string;
};

使用 Readonly<MyType>,会得到一个新的类型:

typescript
type ReadonlyMyType = Readonly<MyType>; // { readonly a: number; readonly b: string; }

Readonly<Type> 源码:

typescript
type Readonly<T> = {
  readonly [P in keyof T]: T[P];
};
4)Record<Keys, Type>

它可以创建一个类型,这个类型的属性名来自 Keys,属性值的类型为 Type。

例如,想创建一个类型,这个类型的属性名是 'a' 和 'b',属性值的类型是 number,可以使用 Record<'a' | 'b', number>

typescript
type MyRecord = Record<'a' | 'b', number>; // { a: number; b: number; }

Record<Keys, Type> 源码:

typescript
type Record<K extends keyof any, T> = {
  [P in K]: T;
};
5)Pick<Type, Keys>

从 Type 中挑选出一组属性 Keys,并创建一个新的类型。

typescript
type MyType = {
  a: number;
  b: string;
  c: boolean;
};

使用 Pick<Type, Keys>,会得到一个新的类型:

typescript
type PickedType = Pick<MyType, 'a' | 'b'>; // { a: number; b: string; }

Pick<Type, Keys> 源码:

typescript
type Pick<T, K extends keyof T> = {
  [P in K]: T[P];
};
6)Omit<Type, Keys>

Pick<Type, Keys> 相反。

typescript
/* -------------------------------------- 基础示例 -------------------------------------- */
interface User {
  id: number;
  name: string;
  email: string;
  password: string;
}

// 过滤多个属性
type BasicUser = Omit<User, 'password' | 'email'>;
// 结果:
// {
//   id: number;
//   name: string;
// }

/* -------------------------------------- 基础示例 -------------------------------------- */
// 排除所有可选属性或所有字符串类型的属性等
type ExcludeStrings<T> = {
  [K in keyof T as T[K] extends string ? never : K]: T[K]
};

// 使用
type PersonWithoutStrings = ExcludeStrings<Person>;
// 结果类型
// {
//   age: number;
// }

在表达式 [K in keyof T as T[K] extends string ? never : K]: T[K] 中:

  • keyof T:遍历类型 T 的所有属性键。

  • as T[K] extends string ? never : K:对于每一个键 K,检查 T 中对应的值的类型 T[K] 是否是字符串类型:

    • 如果是 (T[K] extends string),则使用 never 作为该属性的键类型。在 TypeScript 中,never 类型用作键时,意味着这个属性会被排除。

    • 如果不是,就保持原来的键 K。

  • [K in ... as ...]: T[K]:根据上述条件,创建一个新的类型,其中包含的属性只有那些原类型中值不是字符串的属性。

Omit<Type, Keys> 源码:

typescript
type Omit<T, K extends keyof any> = Pick<T, Exclude<keyof T, K>>;

// 如果每次都pick可能类型太多了
type HYOmit<T, K> = {
  [P in keyof T as P extends K ? never : P]: T[P]
}
7)Exclude<UnionType, ExcludedMembers>

可以从 UnionType 中排除出 ExcludedMembers,并创建一个新的类型。

例如,有一个联合类型:

typescript
type MyType = 'a' | 'b' | 'c';

然后使用 Exclude<MyType, 'a' | 'b'>,会得到一个新的类型:

typescript
type ExcludedType = Exclude<MyType, 'a' | 'b'>; // 'c'

Exclude<UnionType, ExcludedMembers> 源码:

typescript
type Exclude<T, U> = T extends U ? never : T;
8)Extract<Type, Union>

可以从 Type 中提取出可以赋值给 Union 的类型。

例如,有一个联合类型:

typescript
type MyType = 'a' | 'b' | 'c'

然后使用 Extract<MyType, 'a' | 'b'>,会得到一个新的类型:

typescript
type ExtractedType = Extract<MyType, 'a' | 'b'>; // 'a' | 'b'

Extract<Type, Union> 源码:

typescript
type Extract<T, U> = T extends U ? T : never;
9)NonNullable<Type>

用于构造一个类型,这个类型从 Type 中排除了所有的 null 、 undefined 的类型。

typescript
// 构造一个类型,排除 `null` 和 `undefined`。
type NonNullable<T> = T extends undefined | null ? never : T;

type unionType = string | number | undefined | null;
type unionType2 = NonNullable<unionType>;
10)
11)InstanceType<T>

用于获取构造函数类型 T 的实例类型。换句话说,它返回由构造函数 T 创建的对象类型。

语法

typescript
type InstanceType<T extends new (...args: any) => any> = T extends new (...args: any) => infer R ? R : any;

详细解释

  • T extends new (...args: any) => any:这是一个泛型约束,确保 T 是一个构造函数类型。
  • T extends new (...args: any) => infer R ? R : any:这是条件类型,检查 T 是否是一个构造函数类型。
    • 如果是,则使用 infer 关键字推断构造函数的返回类型 R
    • 如果不是,则返回 any 类型。

示例

以下是一些示例,展示如何使用 InstanceType 工具类型:

示例 1:简单类

typescript
class Person {
  name: string;
  constructor(name: string) {
    this.name = name;
  }
}

type PersonInstance = InstanceType<typeof Person>;

// PersonInstance 的类型为 Person
const person: PersonInstance = new Person("Alice");
console.log(person.name); // 输出: Alice

在这个示例中:

  • typeof Person 是构造函数类型 new (name: string) => Person
  • InstanceType<typeof Person> 返回 Person 类型。

示例 2:工厂函数返回类

typescript
function createPersonClass() {
  return class {
    name: string;
    constructor(name: string) {
      this.name = name;
    }
  };
}

const PersonClass = createPersonClass();
type PersonInstance = InstanceType<typeof PersonClass>;

// PersonInstance 的类型为 createPersonClass 返回的类的实例类型
const person: PersonInstance = new PersonClass("Bob");
console.log(person.name); // 输出: Bob

在这个示例中:

  • typeof PersonClass 是工厂函数返回的类的构造函数类型。
  • InstanceType<typeof PersonClass> 返回该类的实例类型。

使用场景

InstanceType 工具类型在以下场景中非常有用:

  1. 类型推断:从构造函数类型推断实例类型。
  2. 泛型编程:在泛型函数或类中使用构造函数类型时,确保类型安全。
  3. 类型转换:将构造函数类型转换为实例类型,以便在类型注解中使用。

总结来说,InstanceType 是一个强大的工具类型,用于处理和操作构造函数及其实例类型,增强了 TypeScript 的类型系统的灵活性和表达能力。

七、类型断言

在 TypeScript 中,类型断言是一种方式,它允许你告诉编译器你比它更了解某个值的类型。类型断言并不会改变数据的实际类型,它只是在编译时期提供类型检查。

在 TypeScript 中,有两种语法可以进行类型断言:

尖括号语法

typescript
let someValue: any = "this is a string";
let strLength: number = (<string>someValue).length;

as 语法

typescript
let someValue: any = "this is a string";
let strLength: number = (someValue as string).length;

因为 someValue 是 any 类型,所以 TypeScript 不知道它有 length 属性,就需要使用类型断言来告诉 TypeScript,someValue 是 string 类型。

注意:在 TypeScript 中使用 JSX 时,只有 as 语法断言是被允许的。

疑问:怎么知道 a 标签的类型是 HTMLAnchorElement?怎么知道 div 的类型是什么,span 的类型又是什么?

技巧:选中那个标签,在浏览器控制台,通过 console.dir($0) 打印 DOM 元素,在属性列表的最后面,即可看到该元素的类型。

TypeScript 仅仅允许类型断言转换为一个更加具体或者更不具体的类型。

typescript
const x = "hello" as number;
// 将类型 'string' 转换为类型 'number' 可能是一个错误,因为这两种类型之间没有足够的重叠。如果这是有意为之,那么首先应将表达式转换为 'unknown' 类型。

可以使用双重断言,先断言为 any(或者是 unknown),然后再断言为期望的类型:

typescript
const a = (expr as any) as T;

非空断言操作符(后缀 !)(Non-null Assertion Operator)

非空断言操作符 ! 是一种类型断言,它告诉 TypeScript 编译器,你确定这个表达式的值不会是 null 或 undefined。这样,TypeScript 就不会对这个表达式进行 null 或 undefined 的检查。

typescript
function liveDangerously(x?: number | null) {
  // No error
  console.log(x!.toFixed());
}

就像其他的类型断言,这也不会更改任何运行时的行为。只有当你明确的知道这个值不可能是 null 或者 undefined 时才使用!

八、类型操作符

1. 索引类型查询 / keyof 类型操作符

1)使用

对一个对象类型使用 keyof 操作符,会返回该对象属性名组成的一个字符串或者数字字面量的联合。

例如,考虑以下的对象类型:

typescript
type MyObject = {
  1: string;
  2: number;
  3: boolean;
};

这个例子中,MyObject 的所有键都是数字。如果我们对 MyObject 使用 keyof 操作符,我们将得到数字字面量的联合类型 1 | 2 | 3

typescript
type MyKeys = keyof MyObject; // 1 | 2 | 3

但如果这个类型有一个 string 或者 number 类型的索引签名,keyof 则会直接返回这些类型:

typescript
type Arrayish = { [n: number]: unknown };
type A = keyof Arrayish; // type A = number

type Mapish = { [k: string]: boolean };
type M = keyof Mapish; // type M = string | number

注意:在这个例子中,M 是 string | number,这是因为 JavaScript 对象的属性名会被强制转为一个字符串,所以 obj[0]obj["0"] 是一样的。

数字字面量联合类型

keyof 也可能返回一个数字字面量的联合类型,那什么时候会返回数字字面量联合类型呢,我们可以尝试构建这样一个对象:

typescript
const NumericObject = {
  [1]: "雨下田上一号",
  [2]: "雨下田上二号",
  [3]: "雨下田上三号"
};

type result = keyof typeof NumericObject

// typeof NumbericObject 的结果为:
// {
//   1: string;
//   2: string;
//   3: string;
// }
// 所以最终的结果为:
// type result = 1 | 2 | 3

Symbol

其实 TypeScript 也可以支持 symbol 类型的属性名:

由于 symbolToNumberMap 的键是 Symbol 类型的变量,而 Symbol 是一种独特且不可变的原始数据类型,因此 KS 的类型将是这三个 Symbol 类型变量的联合类型。

typescript
const sym1 = Symbol();
const sym2 = Symbol();
const sym3 = Symbol();

const symbolToNumberMap = {
  [sym1]: 1,
  [sym2]: 2,
  [sym3]: 3,
};

type KS = keyof typeof symbolToNumberMap; // typeof sym1 | typeof sym2 | typeof sym3

这也就是为什么当我们在泛型中像下面的例子中使用,会如此报错:

typescript
function useKey<T, K extends keyof T>(o: T, k: K) {
  var name: string = k; // 类型 'string | number | symbol' 不能赋值给类型 'string'。
}

如果你确定只使用字符串类型的属性名,你可以这样写:

typescript
function useKey<T, K extends Extract<keyof T, string>>(o: T, k: K) {
  var name: string = k; // OK
}

而如果你要处理所有的属性名,你可以这样写:

typescript
function useKey<T, K extends keyof T>(o: T, k: K) {
  var name: string | number | symbol = k;
}

类和接口

对类使用 keyof:

typescript
// 例子一
class Person {
  name: "雨下田上"
}

type result = keyof Person; // type result = "name"
typescript
// 例子二
class Person {
  [1]: string = "雨下田上";
}

type result = keyof Person; // type result = 1

对接口使用 keyof:

typescript
interface Person {
  name: "string";
}

type result = keyof Person; // type result = "name"
2)实战

希望获取一个对象给定属性名的值,为此,需要确保不会获取 obj 上不存在的属性。所以在两个类型之间建立一个约束:

typescript
function getProperty<Type, Key extends keyof Type>(obj: Type, key: Key) {
  return obj[key];
}

let x = { a: 1, b: 2, c: 3, d: 4 };

getProperty(x, "a");
getProperty(x, "m"); // 类型为 'm' 的参数不能赋值给类型为 'a' | 'b' | 'c' | 'd' 的参数。

2. typeof 类型操作符

1)介绍

JavaScript 本身就有 typeof 操作符,可以在表达式上下文中(expression context)使用:

typescript
console.log(typeof "Hello world"); // Prints "string"

而 TypeScript 添加的 typeof 方法可以在类型上下文(type context)中使用,用于获取一个变量或者属性的类型。

typescript
let s = "hello";
let n: typeof s; // let n: string

如果仅仅用来判断基本的类型,自然是没什么太大用,和其他的类型操作符搭配使用才能发挥它的作用。

举个例子:比如搭配 TypeScript 内置的 ReturnType<T>。你传入一个函数类型,ReturnType<T> 会返回该函数的返回值的类型:

typescript
type Predicate = (x: unknown) => boolean;
type K = ReturnType<Predicate>; // type K = boolean

如果我们直接对一个函数名使用 ReturnType,会看到这样一个报错:

typescript
function f() {
  return { x: 10, y: 3 };
}

type P = ReturnType<f>; // “f”指的是一个值,但在此处用作类型。你的意思是“typeof f”吗?

这是因为值(values)和类型(types)并不是一种东西。为了获取函数 f 的类型,就需要使用 typeof:

typescript
function f() {
  return { x: 10, y: 3 };
}
type P = ReturnType<typeof f>;

// typeof f 的结果:
// () => {
//   x: number;
//   y: number;
//  }

// ReturnType<typeof f> 的结果:
// type P = {
//   x: number;
//   y: number;
// }

TypeScript 有意的限制了可以使用 typeof 的表达式的种类。

在 TypeScript 中,只有对标识符(比如变量名)或者他们的属性使用 typeof 才是合法的。这可能会导致一些令人迷惑的问题:

typescript
// Meant to use = ReturnType<typeof msgbox>
let shouldContinue: typeof msgbox("Are you sure you want to continue?");
// 预期的 ','

我们本意是想获取 msgbox("Are you sure you want to continue?") 的返回值的类型,所以直接使用了 typeof msgbox("Are you sure you want to continue?"),看似能正常执行,但实际并不会,这是因为 typeof 只能对标识符和属性使用。而正确的写法应该是:

typescript
ReturnType<typeof msgbox>

大白话就是只可以对已知的东西(标识符与属性)进行 typeof。函数运行是动态的。TypeScript 是要在编译时确定。

2)使用
[1] 对对象使用
typescript
const person = { name: "kevin", age: "18" }
type Kevin = typeof person;

// type Kevin = {
//   name: string;
//   age: string;
// }
[2] 对函数类型使用
typescript
function identity<Type>(arg: Type): Type {
  return arg;
}

type result = typeof identity;
// type result = <Type>(arg: Type) => Type
[3] 对 enum 使用

在 TypeScript 中,enum 是一种新的数据类型,但在具体运行的时候,它会被编译成对象。

typescript
enum UserResponse {
  No = 0,
  Yes = 1,
}

对应编译的 JavaScript 代码为:

typescript
var UserResponse;
(function (UserResponse) {
  UserResponse[UserResponse["No"] = 0] = "No";
  UserResponse[UserResponse["Yes"] = 1] = "Yes";
})(UserResponse || (UserResponse = {}));

如果我们打印一下 UserResponse:

typescript
console.log(UserResponse);

// [LOG]: {
//   "0": "No",
//   "1": "Yes",
//   "No": 0,
//   "Yes": 1
// }

而如果对 UserResponse 使用 typeof:

typescript
type result = typeof UserResponse;

// result 类型类似于:
// {
//   "No": number,
//   "YES": number
// }

// ok
const a: result = {
  "No": 2,
  "Yes": 3
}

不过对一个 enum 类型只使用 typeof 一般没什么用,通常还会搭配 keyof 操作符用于获取属性名的联合字符串:

typescript
type result = keyof typeof UserResponse;
// type result = "No" | "Yes"

九、索引访问类型 / 索引类型访问

1. 使用

例一

使用索引访问类型查找另外一个类型上的特定属性:

typescript
type Person = { age: number; name: string; alive: boolean };
type Age = Person["age"]; // type Age = number

因为索引名本身就是一个类型,所以也可以使用联合、keyof 或者其他类型:

typescript
type I1 = Person["age" | "name"]; // type I1 = string | number

type I2 = Person[keyof Person]; // type I2 = string | number | boolean

type AliveOrName = "alive" | "name";
type I3 = Person[AliveOrName]; // type I3 = string | boolean

注意:查找一个不存在的属性,TypeScript 会报错。

例二

使用 number 来获取数组元素的类型。结合 typeof 可以方便的捕获数组字面量的元素类型:

typescript
const MyArray = [
  { name: "Alice", age: 15 },
  { name: "Bob", age: 23 },
  { name: "Eve", age: 38 },
];

type Person = typeof MyArray[number];

// type Person = {
//   name: string;
//   age: number;
// }

type Age = typeof MyArray[number]["age"]; // type Age = number
// Or
type Age2 = Person["age"]; // type Age2 = number

2. 实战

有这样一个业务场景,一个页面要用在不同的 APP 里,比如淘宝、天猫、支付宝,根据所在 APP 的不同,调用的底层 API 会不同。

typescript
type app = 'TaoBao' | 'Tmall' | 'Alipay';

function getPhoto(app: app) {
  // ...
}

getPhoto('TaoBao'); // ok
getPhoto('whatever'); // not ok

结合 typeof 和本节的内容实现:

typescript
const APP = ['TaoBao', 'Tmall', 'Alipay'] as const; // 从 string[] 变为只读的元组
type app = typeof APP[number]; // type app = "TaoBao" | "Tmall" | "Alipay"

function getPhoto(app: app) {
  // ...
}

getPhoto('TaoBao'); // ok
getPhoto('whatever'); // not ok

来一步步解析:

as const 作用是什么?as const 将字符串数组变为 readonly 的元组类型。

type app = typeof APP[number]; 代码可以改为 type app = typeof APP; 吗?不可以。

typescript
type typeOfAPP = typeof APP; // type typeOfAPP = readonly ["TaoBao", "Tmall", "Alipay"]

十、条件类型

1.基本使用

帮助我们描述输入类型和输出类型之间的关系。

条件类型的写法有点类似于 JavaScript 中的条件表达式(condition ? trueExpression : falseExpression):

typescript
SomeType extends OtherType ? TrueType : FalseType;

条件类型可以帮助我们减少重载签名。例如:一个函数接受 2 个类型的参数。

typescript
interface IdLabel {
  id: number /* some fields */;
}
interface NameLabel {
  name: string /* other fields */;
}

function createLabel(id: number): IdLabel;
function createLabel(name: string): NameLabel;
function createLabel(nameOrId: string | number): IdLabel | NameLabel;
function createLabel(nameOrId: string | number): IdLabel | NameLabel {
  throw "unimplemented";
}

如果一个库这样写,就会变得非常笨重。如果增加一种新的类型,重载的数量将呈指数增加。其实完全可以把逻辑写在条件类型中:

typescript
type NameOrId<T extends number | string> = T extends number
  ? IdLabel
  : NameLabel;

使用这个条件类型,可以简化掉函数重载:

typescript
function createLabel<T extends number | string>(idOrName: T): NameOrId<T> {
  throw "unimplemented";
}

let a = createLabel("typescript"); // let a: NameLabel

let b = createLabel(2.8); // let b: IdLabel

let c = createLabel(Math.random() ? "hello" : 42); // let c: NameLabel | IdLabel

2. 在条件类型里推断

当在 extends 子句中使用 infer 关键字时,实际上是在声明一个类型变量,这个类型变量将会持有被推断出的类型。然后,可以在条件类型的 true 分支(即 ? 后面的部分)中使用这个类型变量。

例如,下面的 Unpacked 类型可以用来获取一个数组的元素类型:

typescript
type Unpacked<T> = T extends (infer U)[] ? U : T;

3. 分发条件类型

当在泛型中使用条件类型的时候,如果传入一个联合类型,就会变成分发的。

typescript
type ToArray<Type> = Type extends any ? Type[] : never;

type StrArrOrNumArr = ToArray<string | number>; // type StrArrOrNumArr = string[] | number[]

通常这是我们期望的行为,如果要避免这种行为,可以用方括号包裹 extends 关键字的每一部分。

typescript
type ToArrayNonDist<Type> = [Type] extends [any] ? Type[] : never;

// 'StrArrOrNumArr' 不再是一个联合类型。
type StrArrOrNumArr = ToArrayNonDist<string | number>; // type StrArrOrNumArr = (string | number)[]

十一、映射类型

1. 基本使用

一个类型需要基于另外一个类型,但是又不想拷贝一份,这个时候可以考虑使用映射类型。

映射类型,就是使用了 PropertyKeys 联合类型的泛型,其中 PropertyKeys 多是通过 keyof 创建,然后循环遍历键名创建一个类型:

typescript
type OptionsFlags<Type> = {
  [Property in keyof Type]: boolean;
};

在这个例子中,OptionsFlags 会遍历 Type 所有的属性,然后设置为布尔类型。

typescript
type FeatureFlags = {
  darkMode: () => void;
  newUserProfile: () => void;
};

type FeatureOptions = OptionsFlags<FeatureFlags>;
// type FeatureOptions = {
//   darkMode: boolean;
//   newUserProfile: boolean;
// }

2. 映射修饰符

在使用映射类型时,有两个额外的修饰符可能会用到,一个是 readonly,用于设置属性只读,一个是 ?,用于设置属性可选。

可以通过前缀 - 或者 + 删除或者添加这些修饰符,如果没有写前缀,相当于使用了 + 前缀。

typescript
// 删除属性中的只读属性
type CreateMutable<Type> = {
  -readonly [Property in keyof Type]: Type[Property];
};

type LockedAccount = {
  readonly id: string;
  readonly name: string;
};

type UnlockedAccount = CreateMutable<LockedAccount>;

// type UnlockedAccount = {
//   id: string;
//   name: string;
// }
typescript
// 删除属性中的可选属性
type Concrete<Type> = {
  [Property in keyof Type]-?: Type[Property];
};

type MaybeUser = {
  id: string;
  name?: string;
  age?: number;
};

type User = Concrete<MaybeUser>;
// type User = {
//   id: string;
//   name: string;
//   age: number;
// }

3. 通过 as 实现键名重新映射

在 TypeScript 4.1 及以后,可以在映射类型中使用 as 语句实现键名重新映射:

typescript
type MappedTypeWithNewProperties<Type> = {
  [Properties in keyof Type as NewKeyType]: Type[Properties]
}

使用

typescript
type NewKeyType = `new_${string}`;

type OriginalType = {
  foo: number;
  bar: string;
};

type NewType = MappedTypeWithNewProperties<OriginalType>;

// 结果类似于:
// {
//   new_foo: number;
//   new_bar: string;
// }

十二、模板字面量类型

1. 基本使用

模板字面量类型以字符串字面量类型为基础,可以通过联合类型扩展成多个字符串。它们跟 JavaScript 的模板字符串是相同的语法,但是只能用在类型操作中。

typescript
type EmailLocaleIDs = "welcome_email" | "email_heading";
type FooterLocaleIDs = "footer_title" | "footer_sendoff";

type AllLocaleIDs = `${EmailLocaleIDs | FooterLocaleIDs}_id`;
// type AllLocaleIDs = "welcome_email_id" | "email_heading_id" | "footer_title_id" | "footer_sendoff_id"

类型中的字符串联合类型

typescript
type PropEventSource<Type> = {
  on(eventName: `${string & keyof Type}Changed`, callback: (newValue: any) => void): void;
};

// 创建一个带有 'on' 方法的 '被观察对象',以便你可以监视属性的变化。

declare function makeWatchedObject<Type>(obj: Type): Type & PropEventSource<Type>;

模板字面量的推断

typescript
type PropEventSource<Type> = {
  on<Key extends string & keyof Type>
    (eventName: `${Key}Changed`, callback: (newValue: Type[Key]) => void ): void;
};

declare function makeWatchedObject<Type>(obj: Type): Type & PropEventSource<Type>;

const person = makeWatchedObject({
  firstName: "Saoirse",
  lastName: "Ronan",
  age: 26
});

person.on("firstNameChanged", newName => {                             
  // (parameter) newName: string
  console.log(`new name is ${newName.toUpperCase()}`);
});

person.on("ageChanged", newAge => {
  // (parameter) newAge: number
  if (newAge < 0) {
    console.warn("warning! negative age");
  }
})

再看一个例子。

typescript
type Getters<Type> = {
  [Property in keyof Type as `get${Capitalize<string & Property>}`]: () => Type[Property]
};

interface Person {
  name: string;
  age: number;
  599: string;
}

type PersonGetters = Getters<Person>;
// 等同于 
// {
//   getName: () => string;
//   getAge: () => number;
// }

2. 内置字符操作类型

TypeScript 的一些类型可以用于字符操作,这些类型考虑到性能,被内置在编译器中,你不能在 .d.ts 文件里找到它们。

1)Uppercase

把每个字符转为大写形式:

typescript
type Greeting = "Hello, world"
type ShoutyGreeting = Uppercase<Greeting> // type ShoutyGreeting = "HELLO, WORLD"

type ASCIICacheKey<Str extends string> = `ID-${Uppercase<Str>}`
type MainID = ASCIICacheKey<"my_app"> // type MainID = "ID-MY_APP"
2)Lowercase

把每个字符转为小写形式:

typescript
type Greeting = "Hello, world"
type QuietGreeting = Lowercase<Greeting> // type QuietGreeting = "hello, world"

type ASCIICacheKey<Str extends string> = `id-${Lowercase<Str>}`
type MainID = ASCIICacheKey<"MY_APP"> // type MainID = "id-my_app"
3)Capitalize

把字符串的第一个字符转为大写形式:

typescript
type LowercaseGreeting = "hello, world";
type Greeting = Capitalize<LowercaseGreeting>; // type Greeting = "Hello, world"
4)Uncapitalize

把字符串的第一个字符转换为小写形式:

typescript
type UppercaseGreeting = "HELLO WORLD";
type UncomfortableGreeting = Uncapitalize<UppercaseGreeting>; // type UncomfortableGreeting = "hELLO WORLD"

第三章:模块

一、TypeScript 具体的 ES 模块语法

1. 基本语法

跟 JavaScript 一样,使用相同的语法来导出和导入。

typescript
// @filename: animal.ts
export type Cat = { breed: string; yearOfBirth: number };

export interface Dog {
  breeds: string[];
  yearOfBirth: number;
}

// @filename: app.ts
import { Cat, Dog } from "./animal.ts";
type Animals = Cat | Dog;

2. 推荐的语法

TypeScript 已经在两个方面拓展了 import 语法,方便类型导入。

1)导入类型
typescript
// @filename: animal.ts
export type Cat = { breed: string; yearOfBirth: number };
export type Dog = { breeds: string[]; yearOfBirth: number };
export const createCatName = () => "fluffy";

// @filename: valid.ts
import type { Cat, Dog } from "./animal.ts";
export type Animals = Cat | Dog;

// @filename: app.ts
import type { createCatName } from "./animal.ts";
const name = createCatName(); // “createCatName” 不能用作值,因为它是使用 “import type” 导入的。
2)内置类型导入

TypeScript 4.5 也允许单独的导入,你需要使用 type 前缀 ,表明被导入的是一个类型:

typescript
// @filename: app.ts
import { createCatName, type Cat, type Dog } from "./animal.js";

export type Animals = Cat | Dog;
const name = createCatName();

这些可以让一个非 TypeScript 编译器比如 Babel、swc 或者 esbuild 知道什么样的导入可以被安全移除。

二、declare 关键字

declare 关键字用来告诉编译器,某个类型是存在的,可以在当前文件中使用。

它的主要作用,就是让当前文件可以使用其他文件声明的类型。举例来说,自己的脚本使用外部库定义的函数,编译器会因为不知道外部函数的类型定义而报错,这时就可以在自己的脚本里面使用 declare 关键字,告诉编译器外部函数的类型。这样的话,编译单个脚本就不会因为使用了外部类型而报错。

declare 关键字的重要特点是,它只是通知编译器某个类型是存在的,不用给出具体实现。比如,只描述函数的类型,不给出函数的实现,如果不使用 declare,这是做不到的。

declare 只能用来描述已经存在的变量和数据结构,不能用来声明新的变量和数据结构。另外,所有 declare 语句都不会出现在编译后的文件里面。

三、.d.ts 文件

1. 是什么?

TypeScript 中的 .d.ts 文件是类型声明文件,它们用来为 TypeScript 提供有关 JavaScript 代码结构的类型信息。

2. 加载机制

在 TypeScript 中,.d.ts 文件的引入机制有以下几种情况。

1)同名引入

对于你自己写的模块,TypeScript 会默认引入与该文件同名的类型声明文件。例如,如果你有一个 foo.js 文件和一个同目录下的 foo.d.ts 文件,当你在 TypeScript 中 import 该模块时,类型信息将由 foo.d.ts 提供。

2)自动引入

对于 npm 安装的第三方库,如 lodash,如果你安装了对应的类型声明(例如通过 npm install @types/lodash),TypeScript 会自动识别这些 node_modules/@types 的声明文件,无需显式引入它们。

但也可以通过配置更改。

  • typeRoots 设置类型模块所在的目录,默认是 node_modules/@types
  • 该目录里面的模块会自动加入编译。一旦指定了该属性,就不会再用默认值 node_modules/@types 里面的类型模块。
  • 该属性的值是一个数组,数组的每个成员就是一个目录,它们的路径是相对于 tsconfig.json 位置。
json
{
  "compilerOptions": {
    "typeRoots": ["./typings", "./vendor/types"]
  }
}
3)通过 tsconfig.json 配置自动加载

include 属性是用来指明 TypeScript 编译器应该包含哪些文件的。

json
{
  "include": [
    "src/**/*" // `src/**/*` 表示包含 src 目录下的所有文件和子目录中的所有文件,无论文件的扩展名是什么,都应该纳入 TypeScript 编译的范围
  ]
}

解释:

src/*:这是一个 glob 模式,其中:

  • src/ 表示源代码位于项目根目录下的 src 目录中。
  • ** 表示任意数量的子目录(包括零个),这是一个通配符。
  • * 表示任意数量的字符(不含路径分隔符),也是一个通配符。

如果 tsconfig.json 不做任何特殊设置,默认会加载所有的 .d.ts 文件,包括根目录下和任何文件夹内。

3. 外部定义类型声明

1)第三方库

第一:很多第三方库默认都自带类型声明文件,这种就不用我们管了。

第二:如果没有自带,ts 社区基本上也提供了他们的类型文件。TypeScript 社区主要使用 DefinitelyTyped 仓库,各种类型声明文件都会提交到那里,已经包含了几千个第三方库。

这些声明文件都会作为一个单独的库,发布到 npm 的 @types 名称空间之下。比如,jQuery 的类型声明文件就发布成 @types/jquery 这个库,使用时安装这个库就可以了。

bash
npm install @types/jquery --save-dev

执行上面的命令,@types/jquery 这个库就安装到项目的 node_modules/@types/jquery 目录,里面的 index.d.ts 文件就是 jQuery 的类型声明文件。

2)自定义声明

declare module

declare module 用于声明外部模块的类型。在使用第三方模块(比如某个 npm 包)时,你可以使用这种方法来定义模块的类型声明。

declare module 用来定义模块的类型声明,并且这种声明是可以被导入的。这通常适用于文件模块。在 ES6 模块系统中,任何包含顶层 import 或 export 的文件都被视为一个模块。

ts 中 declare module 这个 module 指的是哪种文件?

在 TypeScript 中,declare module 是用来声明外部模块的。这个"模块"指的是通过 import/require 等方式引入的文件或者库。这可能是一个 npm 库,如 lodash 或 jQuery,或者是你的项目中的另一个 JavaScript/TypeScript 文件。

这个 declare module 'jquery' 里的 'jquery' 就是所谓的"模块"。这里是一个字符串,可以写任何你希望的模块名。这个字符串和你代码中 import/require 的模块名全部匹配,如你写 import $ from 'jquery',那么 TypeScript 就会去找 declare module 'jquery' 里有没有对应的类型声明。

三斜线指令

你可以在文件顶部添加如下指令来显式告诉 TypeScript 引入特定的 .d.ts 文件:

typescript
/// <reference path="my-declaration-file.d.ts" />
/// <reference path="my-declaration-file.d.ts" />

这种方式并不推荐用于模块化的代码,适用于全局脚本的场景。

全局变量

全局变量的声明文件主要有以下几种语法:

  • declare var 声明全局变量
  • declare function 声明全局方法
  • declare class 声明全局类
  • declare enum 声明全局枚举类型
  • declare namespace 声明(含有子属性的)全局对象
  • interface 和 type 声明全局类型

附加

一、类型系统层级

never < 字面量类型 < 包含此字面量类型的联合类型(同一基础类型) < 对应的原始类型 < 包含此原始类型的联合类型。

原始类型 < 原始类型对应的装箱类型 < Object 类型 < any / unknown。

基本类型:
number
string
boolean
undefined
null
symbol
bigint

对象类型:
object
array
tuple
enum
interface
class

函数类型:
Function
具体的函数签名

字面量类型:
字符串字面量类型
数字字面量类型
布尔字面量类型

联合和交叉类型:
联合类型(Union Types):Type1 | Type2
交叉类型(Intersection Types):Type1 & Type2

特殊类型:
any
unknown
never
void

泛型:
泛型函数
泛型接口
泛型类

条件类型:
T extends U ? X : Y

实用工具类型:
Partial<T>
Required<T>
Readonly<T>
Record<K, T>
Pick<T, K>
Omit<T, K>
Exclude<T, U>
Extract<T, U>
NonNullable<T>
ReturnType<T>
InstanceType<T>
Parameters<T>
ConstructorParameters<T>
ThisParameterType<T>
OmitThisParameter<T>
ThisType<T>

二、ts 封装 axios 库

封装的 axios 需要满足如下功能:

  • 无处不在的代码提示;
  • 灵活的拦截器;
  • 可以创建多个实例,灵活根据项目进行调整;
  • 每个实例,或者说每个接口都可以灵活配置请求头、超时时间等;
  • 取消请求(可以根据 url 取消单个请求也可以取消全部请求)。

版本一:基础封装

typescript
// index.ts
import axios from "axios";
import type { AxiosInstance, AxiosRequestConfig } from "axios";

class Request {
  // axios 实例
  instance: AxiosInstance;

  constructor(config: AxiosRequestConfig) {
    this.instance = axios.create(config);
  }
  request(config: AxiosRequestConfig) {
    return this.instance.request(config);
  }
}

export default Request;

版本二:拦截器封装

首先我们封装一下拦截器,这个拦截器分为三种:

  • 类拦截器
  • 实例拦截器
  • 接口拦截器

接下来我们就分别实现这三个拦截器。

值得注意的是在 axios 最新版本中中请求拦截器的类型已经从 AxiosRequestConfig 变成了 InternalAxiosRequestConfig,下文中还是使用的 AxiosRequestConfig,不过在最后的源码中已经更新。

1. 类拦截器

类拦截器比较容易实现,只需要在类中对 axios.create() 创建的实例调用 interceptors 下的两个拦截器即可,实例代码如下:

typescript
// index.ts
constructor(config: AxiosRequestConfig) {
  this.instance = axios.create(config)

  this.instance.interceptors.request.use(
    (res: AxiosRequestConfig) => {
      console.log('全局请求拦截器')
      return res
    },
    (err: any) => err,
  )
  this.instance.interceptors.response.use(
    // 因为我们接口的数据都在res.data下,所以我们直接返回res.data
    (res: AxiosResponse) => {
      console.log('全局响应拦截器')
      return res.data
    },
    (err: any) => err,
  )
}

我们在这里对响应拦截器做了一个简单的处理,就是将请求结果中的 .data 进行返回,因为我们对接口请求的数据主要是存在在 .data 中,跟 data 同级的属性我们基本是不需要的。

2. 实例拦截器

实例拦截器是为了保证封装的灵活性,因为每一个实例中的拦截后处理的操作可能是不一样的,所以在定义实例时,允许我们传入拦截器。

首先我们定义一下 interface,方便类型提示,代码如下:

typescript
// types.ts
import type { AxiosRequestConfig, AxiosResponse } from "axios";
export interface RequestInterceptors {
  // 请求拦截
  requestInterceptors?: (config: AxiosRequestConfig) => AxiosRequestConfig;
  requestInterceptorsCatch?: (err: any) => any;
  // 响应拦截
  responseInterceptors?: (config: AxiosResponse) => AxiosResponse;
  responseInterceptorsCatch?: (err: any) => any;
}
// 自定义传入的参数
export interface RequestConfig extends AxiosRequestConfig {
  interceptors?: RequestInterceptors;
}

定义好基础的拦截器后,我们需要改造我们传入的参数的类型,因为 axios 提供的 AxiosRequestConfig 是不允许我们传入拦截器的,所以说我们自定义了 RequestConfig,让其继承与 AxiosRequestConfig。

剩余部分的代码也比较简单,如下所示:

typescript
// index.ts
import axios, { AxiosResponse } from "axios";
import type { AxiosInstance, AxiosRequestConfig } from "axios";
import type { RequestConfig, RequestInterceptors } from "./types";

class Request {
  // axios 实例
  instance: AxiosInstance;
  // 拦截器对象
  interceptorsObj?: RequestInterceptors;

  constructor(config: RequestConfig) {
    this.instance = axios.create(config);
    this.interceptorsObj = config.interceptors;

    this.instance.interceptors.request.use(
      (res: AxiosRequestConfig) => {
        console.log("全局请求拦截器");
        return res;
      },
      (err: any) => err
    );

    // 使用实例拦截器
    this.instance.interceptors.request.use(
      this.interceptorsObj?.requestInterceptors,
      this.interceptorsObj?.requestInterceptorsCatch
    );
    this.instance.interceptors.response.use(
      this.interceptorsObj?.responseInterceptors,
      this.interceptorsObj?.responseInterceptorsCatch
    );
    // 全局响应拦截器保证最后执行
    this.instance.interceptors.response.use(
      // 因为我们接口的数据都在res.data下,所以我们直接返回res.data
      (res: AxiosResponse) => {
        console.log("全局响应拦截器");
        return res.data;
      },
      (err: any) => err
    );
  }
}

拦截器的执行顺序为实例请求 → 类请求 → 实例响应 → 类响应;这样我们就可以在实例拦截上做出一些不同的拦截。

3. 接口拦截

现在我们对单一接口进行拦截操作,首先我们将 AxiosRequestConfig 类型修改为 RequestConfig 允许传递拦截器;然后我们在类拦截器中将接口请求的数据进行了返回,也就是说在request()方法中得到的类型就不是 AxiosResponse 类型了。

我们查看 axios 的 index.d.ts 中,对 request() 方法的类型定义如下:

typescript
// type.ts
request<T = any, R = AxiosResponse<T>, D = any>(config: AxiosRequestConfig<D>): Promise<R>;

也就是说它允许我们传递类型,从而改变 request() 方法的返回值类型,我们的代码如下:

typescript
// index.ts
request<T>(config: RequestConfig): Promise<T> {
  return new Promise((resolve, reject) => {
    // 如果我们为单个请求设置拦截器,这里使用单个请求的拦截器
    if (config.interceptors?.requestInterceptors) {
      config = config.interceptors.requestInterceptors(config)
    }
    this.instance
      .request<any, T>(config)
      .then(res => {
        // 如果我们为单个响应设置拦截器,这里使用单个响应的拦截器
        if (config.interceptors?.responseInterceptors) {
          res = config.interceptors.responseInterceptors<T>(res)
        }

        resolve(res)
      })
      .catch((err: any) => {
        reject(err)
      })
  })
}

这里还存在一个细节,就是我们在拦截器接受的类型一直是 AxiosResponse 类型,而在类拦截器中已经将返回的类型改变,所以说我们需要为拦截器传递一个泛型,从而使用这种变化,修改 types.ts 中的代码,示例如下:

typescript
// index.ts
export interface RequestInterceptors {
  // 请求拦截
  requestInterceptors?: (config: AxiosRequestConfig) => AxiosRequestConfig;
  requestInterceptorsCatch?: (err: any) => any;
  // 响应拦截
  responseInterceptors?: <T = AxiosResponse>(config: T) => T;
  responseInterceptorsCatch?: (err: any) => any;
}

请求接口拦截是最前执行,而响应拦截是最后执行。

版本三:封装请求方法

现在我们就来封装一个请求方法,首先是类进行实例化示例代码如下:

typescript
// index.ts
import Request from "./request";

const request = new Request({
  baseURL: import.meta.env.BASE_URL,
  timeout: 1000 * 60 * 5,
  interceptors: {
    // 请求拦截器
    requestInterceptors: config => {
      console.log("实例请求拦截器");

      return config;
    },
    // 响应拦截器
    responseInterceptors: result => {
      console.log("实例响应拦截器");
      return result;
    }
  }
});

然后我们封装一个请求方法, 来发送网络请求,代码如下:

typescript
// src/server/index.ts
import Request from "./request";

import type { RequestConfig } from "./request/types";
interface YWZRequestConfig<T> extends RequestConfig {
  data?: T;
}
interface YWZResponse<T> {
  code: number;
  message: string;
  data: T;
}

/**
 * @description: 函数的描述
 * @interface D 请求参数的interface
 * @interface T 响应结构的intercept
 * @param {YWZRequestConfig} config 不管是GET还是POST请求都使用data
 * @returns {Promise}
 */
const ywzRequest = <D, T = any>(config: YWZRequestConfig<D>) => {
  const { method = "GET" } = config;
  if (method === "get" || method === "GET") {
    config.params = config.data;
  }
  return request.request<YWZResponse<T>>(config);
};

export default ywzRequest;

该请求方式默认为 GET,且一直用 data 作为参数。

参考文章

Updated at:

preview
图片加载中
预览

Released under the MIT License.