typescript成长之路

TypeScript 是 JavaScript 的一个超集,主要提供了类型系统对 ES6 的支持,它由 Microsoft 开发,代码开源于 GitHub 上。

它的第一个版本发布于 2012 年 10 月,经历了多次更新后,现在已成为前端社区中不可忽视的力量,不仅在 Microsoft 内部得到广泛运用,而且 Google 开发的 Angular 从 2.0 开始就使用了 TypeScript 作为开发语言,Vue 3.0 也使用 TypeScript 进行了重构。

image-20230325140354690

学习方法:

先快速过,可标记疑难点,必须回顾所学,分析总结,转化成自己的理解与应用。

快速入门

官方文档

为什么推荐去官方文档查看

第三方文档可能不够细,甚至断章取义,原本一个容易理解的概念,第三方文档解释就变了味道。

常用命令

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
# 输出ts帮助,即指令用法
# 如果是在已存在的项目目录下执行该命令,则是编译项目
$ tsc
# 查看当前安装的版本
$ tsc -v
# 初始化项目
$ tsc --init

Created a new tsconfig.json with:
TS
target: es2016
module: commonjs
strict: true
esModuleInterop: true
skipLibCheck: true
forceConsistentCasingInFileNames: true


You can learn more at https://aka.ms/tsconfig

# 监听文件改动
$ tsc --watch

常用依赖

ts-node

简化 typescript 运行步骤,可直接在 node.js 环境中执行 ts 代码。

官网描述:用于 node.js 的 TypeScript 执行和 REPL,具有源映射和本机 ESM 支持。

详见

当然,如果适用vscode开发,可以使用Code Runner插件,选择需要运行的文件,鼠标右键选择Run Code即可执行编译输出。

nodemon

监控 node.js 应用程序中的任何更改并自动重启服务器 - 非常适合开发。

详见

在package.json中配置如下

1
2
3
4
5
{
"script": {
"dev": "nodemon --watch src/ -e ts --exec ts-node ./src/app.ts"
}
}

tsconfig.json配置

详见官网

打包辅助工具

parcel

详见

html中引入ts,可编译打包成js。

webpack

详见

自定义打包辅助工具

详见

重点记忆

有关功能的更多信息

函数重载

类型操作

从类型中创建类型

  • 泛型 - 带参数的类型
  • Keyof 类型操作符- keyof 操作符创建新类型
  • Typeof 类型操作符 - 使用 typeof 操作符来创建新的类型
  • 索引访问类型 - 使用 Type[‘a’] 语法来访问一个类型的子集
  • 条件类型 - 在类型系统中像if语句一样行事的类型
  • 映射类型 - 通过映射现有类型中的每个属性来创建类型
  • 模板字面量类型 - 通过模板字面字符串改变属性的映射类型

泛型

泛型是一种通用的编程概念,它允许在编写代码时不指定具体的类型,而是在使用代码时提供类型。通过使用泛型,可以编写更加通用和灵活的代码,使其能够适应不同的数据类型和数据结构。

在 TypeScript 中,泛型通常使用尖括号 < > 包裹,后跟一个标识符,例如 TUK 等等。这个标识符可以在代码中用作类型注释或泛型函数或类的参数。

例如,下面是一个泛型函数的例子,它使用类型参数 T 来表示一个数组中元素的类型,并返回数组中所有元素的和:

1
2
3
4
5
6
7
8
9
function sum<T>(numbers: T[]): T {
let result = 0;

for (let i = 0; i < numbers.length; i++) {
result += numbers[i];
}

return result;
}

在这个例子中,sum 函数接受一个类型为 T 的数组,并返回类型为 T 的值。当我们使用 sum 函数时,需要提供一个实际的类型作为类型参数,例如:

1
2
3
4
const numbers = [1, 2, 3, 4, 5];
const result = sum(numbers);

console.log(result); // 输出 15

在这个例子中,我们将 numbers 数组作为参数传递给 sum 函数,并在调用函数时使用 number 类型作为类型参数。函数将返回一个 number 类型的值,它是数组中所有元素的和。

通过使用泛型,我们可以编写可重用的代码,使其能够适应不同的数据类型和数据结构,从而提高代码的灵活性和通用性。

什么是类型参数

泛型也可以称为类型参数。在 TypeScript 中,泛型可以被用作类型参数,用于指定在编写代码时不确定的数据类型。

因此,泛型和类型参数的概念是相互关联的。泛型是一种通用的编程概念,用于指定在编写代码时不确定的数据类型,而类型参数则是指在使用泛型时需要提供的具体类型。

使用泛型类型变量

首先,让我们做一下泛型的 “ hello world”:身份函数。身份函数是一个函数,它将返回传入的任何内容。你可以用类似于echo命令的方式来考虑它。

如果没有泛型,我们将不得不给身份函数一个特定的返回值类型。

1
2
3
function identity(arg: number): number {
return arg;
}

或者,我们可以用任意类型来描述身份函数。

1
2
3
function identity(arg: any): any {
return arg;
}

使用 any 当然是通用的,因为它将使函数接受 arg 类型的任何和所有的类型。实际上我们在函数返回时失去了关于该类型的信息。如果我们传入一个数字,我们唯一的信息就是任何类型都可以被返回。

相反,我们需要一种方法来捕获参数的类型,以便我们也可以用它来表示返回的内容。在这里,我们将使用一个类型变量,这是一种特殊的变量,对类型而不是数值起作用

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

不借助编译器的类型推断写法

1
let output = identity<string>("myString");

相反,编译器只是查看了 “myString “这个值,并将Type设置为其类型。

1
let output = identity("myString");

如果我们想在每次调用时将参数 arg 的长度记录到控制台,该怎么办?我们可能很想这样写:

1
2
3
4
function loggingIdentity<Type>(arg: Type): Type {
console.log(arg.length); // error 类型“Type”上不存在属性“length”。
return arg;
}

当我们这样做时,编译器会给我们一个错误,说我们在使用 arg 的 .length 成员,但我们没有说arg 有这个成员。记住,我们在前面说过,这些类型的变量可以代表任何和所有的类型,所以使用这个函数的人可以传入一个 number ,而这个数字没有一个 .length 成员。

比方说,我们实际上是想让这个函数在 Type 的数组上工作,而不是直接在 Type 上工作。既然我们在处理数组,那么 .length 成员应该是可用的。我们可以像创建其他类型的数组那样来描述它。

1
2
3
4
function loggingIdentity<Type>(arg: Type[]): Type[] {
console.log(arg.length);
return arg;
}

你可以把 loggingIdentity 的类型理解为 “通用函数 loggingIdentity 接收一个类型参数 Type 和

一个参数 arg , arg 是一个 Type 数组,并返回一个 Type 数组。” 如果我们传入一个数字数组,我们会得到一个数字数组,因为Type会绑定到数字。这允许我们使用我们的通用类型变量 Type 作为我们正在处理的类型的一部分,而不是整个类型,给我们更大的灵活性。

我们也可以这样来写这个例子:

1
2
3
4
5
function loggingIdentity<Type>(arg: Array<Type>): Array<Type> {
console.log(arg.length); // 数组有一个.length,所以不会再出错了
return arg;
}
// Array<Type>:整体是一个数组类型,Type是其中的一部分类型

泛型类型

在前面的部分中,我们创建了适用于一系列类型的通用身份函数。在本节中,我们将探讨函数本身的类型以及如何创建通用接口。

泛型函数的类型与非泛型函数的类型一样,首先列出类型参数,类似于函数声明:

1
2
3
4
5
function identity<Type>(arg: Type): Type {
return arg;
}

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

我们也可以为类型中的泛型类型参数使用不同的名称,只要类型变量的数量和类型变量的使用方式一致即可。

1
2
3
4
5
function identity<Type>(arg: Type): Type {
return arg;
}

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

我们还可以将泛型类型写成对象字面量类型的调用签名:

1
2
3
4
5
function identity<Type>(arg: Type): Type {
return arg;
}

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

感觉有种匿名函数的写法,怪怪的。

这导致我们编写了第一个通用接口。让我们把前面例子中的对象字面量移到一个接口中:

1
2
3
4
5
6
7
8
9
interface GenericIdentityFn {
<Type>(arg: Type): Type;
}

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

let myIdentity: GenericIdentityFn = identity;

在类似的示例中,我们可能希望将通用参数移动为整个接口的参数。这让我们可以看到我们通用的类型(例如,Dictionary<string>而不仅仅是Dictionary)。这使得类型参数对接口的所有其他成员可见。

1
2
3
4
5
6
7
8
9
interface GenericIdentityFn<Type> {
(arg: Type): Type;
}

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

let myIdentity: GenericIdentityFn<number> = identity;

请注意,我们的示例已更改为略有不同。我们现在没有描述泛型函数,而是有一个非泛型函数签名,它是泛型类型的一部分。当我们使用GenericIdentityFn时,我们现在还需要指定相应的类型参数(此处:number),有效地锁定底层调用签名将使用的内容。了解何时将类型参数直接放在调用签名上以及何时将其放在接口本身上将有助于描述类型的哪些方面是通用的。

除了泛型接口,我们还可以创建泛型类。请注意,无法创建通用枚举和命名空间

泛型类

类和接口一样,可以是泛型的。当一个泛型类用new实例化时,其类型参数的推断方式与函数调用的

方式相同。

1
2
3
4
5
6
7
8
class Box<Type> {
contents: Type;
constructor(value: Type) {
this.contents = value;
}
}
// const b: Box<string>
const b = new Box("hello!");

类可以像接口一样使用通用约束和默认值。

静态成员中的类型参数。

1
2
3
4
5
6
class Box<Type> {
// 静态成员不能引用类的类型参数。
static defaultValue: Type;
}
// Box<string>.defaultValue = 'hello'
// console.log(Box<number>.defaultValue)

请记住,类型总是被完全擦除的! 在运行时,只有一个Box.defaultValue属性。这意味着设置Box.defaultValue(如果有可能的话)也会改变Box.defaultValue,这可不是什么好事。一个泛型类的静态成员永远不能引用该类的类型参数。

泛型约束

在我们的loggingIdentity示例中,我们希望能够访问.length的属性arg,但编译器无法证明每个类型都有一个.length属性,因此它警告我们不能做出这种假设。

1
2
3
4
function loggingIdentity<Type>(arg: Type): Type {
console.log(arg.length); // error 类型“Type”上不存在属性“length”。
return arg
}

我们不想使用任何和所有类型,而是希望将此函数限制为使用 具有该.length属性的任何和所有类型。只要类型有这个成员,我们就允许它,但它至少需要有这个成员。为此,我们必须将我们的要求列为限制条件Type

为此,我们将创建一个描述约束的接口。在这里,我们将创建一个具有单个.length属性的接口,然后我们将使用该接口和extends关键字来表示我们的约束:

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

function loggingIdentity<Type extends Lengthwise>(arg: Type): Type {
console.log(arg.length);
return arg;
}

因为泛型函数现在受到约束,所以它不再适用于所有类型:

1
loggingIdentity(3); // error 类型“number”的参数不能赋给类型“Lengthwise”的参数。

相反,我们需要传入其类型具有所有必需属性的值:

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

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

您可以声明一个受另一个类型参数约束的类型参数。例如,在这里我们想从给定名称的对象中获取属性。我们想确保我们不会意外获取obj上不存在的属性,因此我们将在两种类型之间放置一个约束:

1
2
3
4
5
6
7
8
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"); // error 类型“"m"”的参数不能赋给类型“"a" | "b" | "c" | "d"”的参数。

在泛型中使用类类型

在 TypeScript 中使用泛型创建工厂时,需要通过构造函数来引用类类型。例如,

1
2
3
function create<Type>(c: { new (): Type }): Type {
return new c();
}

一个更高级的示例使用原型属性来推断和约束构造函数与类类型的实例端之间的关系。

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
class BeeKeeper {
hasMask: boolean = true;
}

class ZooKeeper {
nametag: string = "Mikle";
}

class Animal {
numLegs: number = 4;
}

class Bee extends Animal {
keeper: BeeKeeper = new BeeKeeper();
}

class Lion extends Animal {
keeper: ZooKeeper = new ZooKeeper();
}

function createInstance<A extends Animal>(c: new () => A): A {
return new c();
}

createInstance(Lion).keeper.nametag;
createInstance(Bee).keeper.hasMask;

此模式用于为混合设计模式提供动力。

类型keyof运算符

运算keyof符采用对象类型并生成其键的字符串或数字文字联合。以下类型 P 与“x” | ”y“是同一类型:

1
2
3
type Point = { x: number; y: number };
// P: type p = keyof Point
type P = keyof Point;

如果类型有一个stringnumber索引签名,keyof将返回这些类型:

1
2
3
4
5
6
7
type Arrayish = { [n: number]: unknown };
// type A = number
type A = keyof Arrayish;

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

请注意,在此示例中,Mstring | number, 这是因为 JavaScript 对象键始终被强制转换为字符串,因此obj[0]始终转为obj["0"].

keyof类型在与映射类型结合使用时变得特别有用,我们稍后将详细了解这一点。

类型运算符

JavaScript 已经有一个typeof可以在表达式上下文中使用的运算符:

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

TypeScript 添加了一个typeof运算符,您可以在类型上下文中使用它来引用变量或属性的类型:

1
2
let s = "hello";
let n: typeof s; // let n: string

这对基本类型不是很有用,但结合其他类型运算符,可以typeof方便地表达许多模式。例如,让我们从查看预定义类型开始ReturnType<T>,它接受一个函数类型并产生它的返回类型:

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

ReturnType如果我们尝试在函数名称上使用,我们会看到一个指示性错误:

1
2
3
4
function f() {
return { x: 10, y: 3 };
}
type P = ReturnType<f>; // error “f”表示值,但在此处用作类型。是否指“类型 f”?

请记住,类型不是一回事。要引用值的类型,我们使用:f typeof

1
2
3
4
5
6
7
8
9
function f() {
return { x: 10, y: 3 };
}
type P = ReturnType<typeof f>;

type P = {
x: number;
y: number;
}

限制

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

typeof具体来说,只有在标识符(即变量名)或其属性上使用才是合法的。这有助于避免编写您认为正在执行但实际上不是的代码的混乱陷阱:

1
2
3
function msgbox() {}
// Meant to use = ReturnType<typeof msgbox>
let shouldContinue: typeof msgbox("Are you sure you want to continue?"); // error 应为“,”。

索引访问类型

我们可以使用索引访问类型来查找另一种类型的特定属性:

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

索引类型本身就是一种类型,因此我们可以keyof完全使用联合、 或其他类型:

1
2
3
4
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

如果您尝试索引一个不存在的属性,您甚至会看到一个错误:

1
type I1 = Person["alve"]; // error 类型“Person”上不存在属性“alve”。

使用任意类型进行索引的另一个示例是使用number获取数组元素的类型。我们可以将它与typeof 结合起来以方便地捕获数组文字的元素类型:

1
2
3
4
5
6
7
8
9
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

您只能在索引时使用类型,这意味着您不能使用 const来进行变量引用:

1
2
3
4
5
6
7
const key = "age";
/**
* error
* 类型“key”不能作为索引类型使用。
* “key”表示值,但在此处用作类型。是否指“类型 key”?
*/
type Age = Person[key];

但是,您可以为类似风格的重构使用类型别名:

1
2
type key = "age";
type Age = Person[key];

条件类型

在最有用的程序的核心,我们必须根据输入做出决定。JavaScript 程序没有什么不同,但考虑到值可以很容易地自省这一事实,这些决定也基于输入的类型。 条件类型有助于描述输入和输出类型之间的关系。

1
2
3
4
5
6
7
8
interface Animal {
live(): void;
}
interface Dog extends Animal {
woof(): void;
}
type Example1 = Dog extends Animal ? number : string; // type Example1 = number
type Example2 = RegExp extends Animal ? number : string; // type Example2 = string

条件类型的形式有点像JavaScript 中的条件表达式 (condition ? trueExpression : falseExpression):

1
SomeType extends OtherType ? TrueType : FalseType;

当左侧的类型extends可分配给右侧的类型时,您将获得第一个分支(“真实”分支)中的类型;否则你会在后一个分支(“false”分支)中得到类型。

从上面的示例中,条件类型可能不会立即看起来有用 - 我们可以告诉自己是否Dog extends Animal选择numberor string!但条件类型的强大之处在于将它们与泛型一起使用。

例如,让我们采用以下createLabel功能:

1
2
3
4
5
6
7
8
9
10
11
12
13
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";
}

createLabel 的这些重载描述了一个 JavaScript 函数,该函数根据其输入的类型做出选择。注意几点:

  1. 如果一个库必须在其 API 中一遍又一遍地做出相同类型的选择,这将变得很麻烦。
  2. 我们必须创建三个重载:一个用于我们确定类型的每种情况(一个用于string,一个用于number),一个用于最一般的情况(采用 a string | number)。对于每个可以处理的新类型createLabel,重载的数量呈指数增长。

相反,我们可以将该逻辑编码为条件类型:

1
2
3
type NameOrId<T extends number | string> = T extends number
? IdLabel
: NameLabel;

然后我们可以使用该条件类型将我们的重载简化为没有重载的单个函数。

1
2
3
4
5
6
7
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

条件类型约束

通常,条件类型的检查会为我们提供一些新信息。就像使用类型保护进行缩小可以为我们提供更具体的类型一样,条件类型的真正分支将通过我们检查的类型进一步限制泛型。

例如,让我们采取以下内容:

1
type MessageOf<T> = T["message"]; // error 类型“"message"”无法用于索引类型“T”。

在此示例中,TypeScript 出错是因为T不知道有一个名为 的属性message。我们可以约束T,TypeScript 将不再抱怨:

1
2
3
4
5
6
7
type MessageOf<T extends { message: unknown }> = T["message"];

interface Email {
message: string;
}

type EmailMessageContents = MessageOf<Email>; // type EmailMessageContents = string

但是,如果我们想MessageOf采用任何类型,并默认为某个属性不可用never时怎么办?message我们可以通过移出约束并引入条件类型来做到这一点:

1
2
3
4
5
6
7
8
9
10
11
12
type MessageOf<T> = T extends { message: unknown } ? T["message"] : never;

interface Email {
message: string;
}

interface Dog {
bark(): void;
}

type EmailMessageContents = MessageOf<Email>; // type EmailMessageContents = string
type DogMessageContents = MessageOf<Dog>; // type DogMessageContents = never

在 true 分支中,TypeScript 知道T 有一个message属性。

作为另一个示例,我们还可以编写一个名为Flatten的类型,将数组类型展平为它们的元素类型,但除此之外别管它们:

1
2
3
4
5
6
7
type Flatten<T> = T extends any[] ? T[number] : T;

// Extracts out the element type.
type Str = Flatten<string[]>; // type Str = string

// Leaves the type alone.
type Num = Flatten<number>; // type Num = number

Flatten给定一个数组类型时,它使用索引number访问来获取 的string[]元素类型。否则,它只返回给定的类型。

在条件类型中进行推断

我们只是发现自己使用条件类型来应用约束,然后提取类型。这最终成为一种常见的操作,条件类型使它变得更容易。

条件类型为我们提供了一种方法,可以使用infer关键字从我们在真实分支中比较的类型进行推断。例如,我们可以推断元素类型Flatten而不是使用索引访问类型“手动”取出它:

1
type Flatten<Type> = Type extends Array<infer Item> ? Item : Type;

在这里,我们使用infer关键字声明性地引入一个新的泛型类型变量 named而不是指定如何在 true 分支中Item检索元素类型。T这使我们不必考虑如何深入挖掘和剖析我们感兴趣的类型的结构。

我们可以使用关键字编写一些有用的辅助类型别名infer。例如,对于简单的情况,我们可以从函数类型中提取返回类型:

1
2
3
4
5
6
7
type GetReturnType<Type> = Type extends (...args: never[]) => infer Return
? Return
: never;

type Num = GetReturnType<() => number>; // type Num = number
type Str = GetReturnType<(x: string) => string>; // type Str = string
type Bools = GetReturnType<(a: boolean, b: boolean) => boolean[]>; // type Bools = boolean[]

当从具有多个调用签名的类型(例如重载函数的类型)进行推断时,将根据最后一个签名进行推断(这大概是最宽松的包罗万象的情况)。不可能根据参数类型列表执行重载决策。

1
2
3
4
5
declare function stringOrNum(x: string): number;
declare function stringOrNum(x: number): string;
declare function stringOrNum(x: string | number): string | number;

type T1 = ReturnType<typeof stringOrNum>; // type T1 = string | number

或者

1
2
3
4
5
6
7
8
function stringOrNum(x: string): number;
function stringOrNum(x: number): string;
function stringOrNum(x: string | number): string | number {
return Math.random() > 0.5 ? 'hello' : 123;
}

type T1 = ReturnType<typeof stringOrNum>; // type T1 = string | number
const t1: T1 = true // error 不能将类型“boolean”分配给类型“string”。

Math.random()是随机的,const t1: T1 = true可有可能error 不能将类型“boolean”分配给类型“number”

分布式条件类型

当条件类型作用于泛型类型时,它们在给定联合类型时变得具有分配性。例如,采用以下内容:

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

如果我们将联合类型插入到 中ToArray,则条件类型将应用于该联合的每个成员。

1
2
3
type ToArray<Type> = Type extends any ? Type[] : never;

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

这里发生的是StrArrOrNumArr分布在:

1
string | number;

并将联合的每个成员类型映射到有效的:

1
ToArray<string> | ToArray<number>;

这给我们留下了:

1
string[] | number[];

通常,分配性是所需的行为。extends为避免这种行为,您可以用方括号将关键字的每一侧括起来。

1
2
3
4
type ToArrayNonDist<Type> = [Type] extends [any] ? Type[] : never;

// 'StrArrOrNumArr' is no longer a union.
type StrArrOrNumArr = ToArrayNonDist<string | number>; // type StrArrOrNumArr = (string | number)[]

映射类型

当您不想重复自己时,有时一种类型需要基于另一种类型。

映射类型建立在索引签名的语法之上,用于声明未提前声明的属性类型:

1
2
3
4
5
6
7
8
type OnlyBoolsAndHorses = {
[key: string]: boolean | Horse;
};

const conforms: OnlyBoolsAndHorses = {
del: true,
rodney: false,
};

映射类型是一种通用类型,它使用PropertyKeys的联合(通常通过 keyof创建)来遍历键以创建类型:

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

在此示例中,OptionsFlags将从类型中获取所有属性Type并将它们的值更改为布尔值。

1
2
3
4
5
6
7
8
9
10
11
12
type FeatureFlags = {
darkMode: () => void;
newUserProfile: () => void;
};

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

映射修饰符

在映射期间可以应用两个额外的修饰符:readonly?分别影响可变性和可选性。

您可以通过前缀-+来删除或添加这些修饰符。如果您不添加前缀,则假定为+

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// Removes 'readonly' attributes from a type's properties
type CreateMutable<Type> = {
-readonly [Property in keyof Type]: Type[Property];
};

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

/*
type UnlockedAccount = {
id: string;
name: string;
}
*/
type UnlockedAccount = CreateMutable<LockedAccount>;
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// Removes 'optional' attributes from a type's properties
type Concrete<Type> = {
[Property in keyof Type]-?: Type[Property];
};

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

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

键重映射通过as

在 TypeScript 4.1 及更高版本中,您可以使用映射类型中的子句as重新映射映射类型中的键:

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

您可以利用模板字面量类型等功能从先前的属性名称创建新的属性名称:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
type Getters<Type> = {
[Property in keyof Type as `get${Capitalize<string & Property>}`]: () => Type[Property];
};

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

/*
type LazyPerson = {
getName: () => string;
getAge: () => number;
getLocation: () => string;
}
*/
type LazyPerson = Getters<Person>;

您可以通过条件类型生成never来过滤掉键:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// Remove the 'kind' property
type RemoveKindField<Type> = {
[Property in keyof Type as Exclude<Property, "kind">]: Type[Property]
};

interface Circle {
kind: "circle";
radius: number;
}

/*
type KindlessCircle = {
radius: number;
}
*/
type KindlessCircle = RemoveKindField<Circle>;

您可以映射任意联合,不仅string | number | symbol 的联合,还可以是任何类型的联合:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
type EventConfig<Events extends { kind: string }> = {
[E in Events as E["kind"]]: (event: E) => void;
}

type SquareEvent = { kind: "square", x: number, y: number };
type CircleEvent = { kind: "circle", radius: number };

/*
type Config = {
square: (event: SquareEvent) => void;
circle: (event: CircleEvent) => void;
}
*/
type Config = EventConfig<SquareEvent | CircleEvent>

进一步探索

映射类型与此类型操作部分中的其他功能配合得很好,例如,这里是一个使用条件类型的映射类型,它返回 truefalse取决于对象是否将属性pii设置为字面意义上的true

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
type ExtractPII<Type> = {
[Property in keyof Type]: Type[Property] extends { pii: true } ? true : false;
};

type DBFields = {
id: { format: "incrementing" };
name: { type: string; pii: true };
};

/*
type ObjectsNeedingGDPRDeletion = {
id: false;
name: true;
}
*/
type ObjectsNeedingGDPRDeletion = ExtractPII<DBFields>;

模板文字类型

模板字面量类型建立在字符串字面量类型之上,并且能够通过联合扩展为多个字符串。

它们与 JavaScript 中的模板文字字符串具有相同的语法,但用于类型位置。当与具体文字类型一起使用时,模板文字通过连接内容生成新的字符串文字类型。

1
2
3
type World = "world";

type Greeting = `hello ${World}`; // type Greeting = "hello world"

当在插值位置使用联合时,类型是每个联合成员可以表示的每个可能字符串文字的集合:

1
2
3
4
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"

对于模板字面量中的每个插值位置,并集交叉相乘:

1
2
3
4
type AllLocaleIDs = `${EmailLocaleIDs | FooterLocaleIDs}_id`;
type Lang = "en" | "ja" | "pt";

type LocaleMessageIDs = `${Lang}_${AllLocaleIDs}`; // type LocaleMessageIDs = "en_welcome_email_id" | "en_email_heading_id" | "en_footer_title_id" | "en_footer_sendoff_id" | "ja_welcome_email_id" | "ja_email_heading_id" | "ja_footer_title_id" | "ja_footer_sendoff_id" | "pt_welcome_email_id" | "pt_email_heading_id" | "pt_footer_title_id" | "pt_footer_sendoff_id"

我们通常建议人们对大型字符串联合使用提前生成,但这在较小的情况下很有用。

类型中的字符串联合

模板字面量类型建立在字符串字面量类型之上,并且能够通过联合扩展为多个字符串。

它们与 JavaScript 中的模板文字字符串具有相同的语法,但用于类型位置。当与具体文字类型一起使用时,模板文字通过连接内容生成新的字符串文字类型。

1
2
type World = "world";
type Greeting = `hello ${World}`; // type Greeting = "hello world"

当在插值位置使用联合时,类型是每个联合成员可以表示的每个可能字符串文字的集合:

1
2
3
4
5
type EmailLocaleIDs = "welcome_email" | "email_heading";
type FooterLocaleIDs = "footer_title" | "footer_sendoff";

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

对于模板字面量中的每个插值位置,并集交叉相乘

1
2
3
4
5
type AllLocaleIDs = `${EmailLocaleIDs | FooterLocaleIDs}_id`;
type Lang = "en" | "ja" | "pt";

// type LocaleMessageIDs = "en_welcome_email_id" | "en_email_heading_id" | "en_footer_title_id" | "en_footer_sendoff_id" | "ja_welcome_email_id" | "ja_email_heading_id" | "ja_footer_title_id" | "ja_footer_sendoff_id" | "pt_welcome_email_id" | "pt_email_heading_id" | "pt_footer_title_id" | "pt_footer_sendoff_id"
type LocaleMessageIDs = `${Lang}_${AllLocaleIDs}`;

我们通常建议人们对大型字符串联合使用提前生成,但这在较小的情况下很有用。

类型中的字符串联合

当基于类型中的信息定义新字符串时,模板字面量的威力就体现出来了。

考虑这样一种情况,函数 ( makeWatchedObject) 添加一个新函数on()调用给传递的对象。在 JavaScript 中,它的调用可能如下所示 makeWatchedObject(baseObject):我们可以想象基础对象看起来像:

1
2
3
4
5
const passedObject = {
firstName = "Saoirse",
lastName = "Ronan",
age: 26
};

on将添加到基础对象的函数需要两个参数,一个 eventName(a string) 和一个callBack(a function)。

这个eventName应该是attributeInThePassedObject + "Changed"; 因此,firstNameChanged从基础对象中的firstName属性派生。

callBack函数在调用时:

  • 应传递与名称关联的类型的值attributeInThePassedObject;因此,由于firstName类型为string,事件的回调firstNameChanged期望在调用时将 astring传递给它。类似地,与关联的事件age应该期望用number参数调用
  • 应该有void返回类型(为了演示的简单性)

因此, 的原始函数签名on()可能是:on(eventName: string, callBack: (newValue: any) => void)。但是,在前面的描述中,我们确定了我们希望在代码中记录的重要类型约束。模板文字类型让我们将这些约束带入我们的代码中。

1
2
3
4
5
6
7
8
9
10
11
const person = makeWatchedObject({
firstName: "Saoirse",
lastName: "Ronan",
age: 26,
});

// makeWatchedObject has added `on` to the anonymous Object

person.on("firstNameChanged", (newValue) => {
console.log(`firstName was changed to ${newValue}!`);
});

请注意,on监听事件"firstNameChanged",而不仅仅是"firstName". on()如果我们要确保符合条件的事件名称集受监视对象中属性名称的联合约束,并在末尾添加“已更改”,我们的天真规范可能会变得更加健壮。虽然我们很乐意在 JavaScript ie 中进行这样的计算Object.keys(passedObject).map(x =>${x}Changed),但类型系统中的模板文字提供了类似的字符串操作方法:

1
2
3
4
5
6
7
type PropEventSource<Type> = {
on(eventName: `${string & keyof Type}Changed`, callback: (newValue: any) => void): void;
};

/// Create a "watched object" with an 'on' method
/// so that you can watch for changes to properties.
declare function makeWatchedObject<Type>(obj: Type): Type & PropEventSource<Type>;

有了这个,我们可以构建一些在给定错误属性时出错的东西:

1
2
3
4
5
6
7
8
9
10
11
12
const person = makeWatchedObject({
firstName: "Saoirse",
lastName: "Ronan",
age: 26
});

person.on("firstNameChanged", () => {});
// Prevent easy human error (using the key instead of the event name)
person.on("firstName", () => {}); // error Argument of type '"firstName"' is not assignable to parameter of type '"firstNameChanged" | "lastNameChanged" | "ageChanged"'.

// It's typo-resistant
person.on("frstNameChanged", () => {}); // error Argument of type '"frstNameChanged"' is not assignable to parameter of type '"firstNameChanged" | "lastNameChanged" | "ageChanged"'.

用模板文字推断

请注意,我们并未受益于原始传递对象中提供的所有信息。给定 a 的变化firstName(即firstNameChanged事件),我们应该期望回调将接收 type string的参数。同样,更改为的回调age应该接收一个number参数。我们天真地使用anyto type thecallBack的参数。同样,模板文字类型可以确保属性的数据类型与该属性的回调的第一个参数的类型相同。

使这成为可能的关键见解是:我们可以使用具有泛型的函数,这样:

  1. 第一个参数中使用的文字被捕获为文字类型
  2. 该文字类型可以被验证为在泛型中的有效属性的联合中
  3. 可以使用索引访问在泛型的结构中查找经过验证的属性的类型
  4. 然后可以应用此类型信息以确保回调函数的参数属于同一类型
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
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");
}
}

这里我们制作成on泛型方法。

当用户使用字符串调用时"firstNameChanged",TypeScript 将尝试为 推断正确的类型Key。为此,它将匹配Key之前的内容"Changed"并推断字符串"firstName"。一旦 TypeScript 弄清楚了这一点,该方法就可以获取原始对象的on类型,在本例中就是这样。同样,当使用调用时,TypeScript 会找到属性的类型。firstName``string``"ageChanged"``age``number

推理可以以不同的方式组合,通常是解构字符串,并以不同的方式重建它们。

内部字符串操作类型

为了帮助进行字符串操作,TypeScript 包含一组可用于字符串操作的类型。这些类型内置于编译器中以提高性能,并且无法在TypeScript 附带的文件.d.ts中找到。

Uppercase

将字符串中的每个字符转换为大写版本。

例子

1
2
3
4
5
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"

Lowercase

将字符串中的每个字符转换为等效的小写字母。

例子

1
2
3
4
5
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"

Capitalize

将字符串中的第一个字符转换为等效的大写字母。

例子

1
2
type LowercaseGreeting = "hello, world";
type Greeting = Capitalize<LowercaseGreeting>; // type Greeting = "Hello, world"

Uncapitalize

将字符串中的第一个字符转换为等效的小写字母。

例子

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

内在字符串操作类型的技术细节

从 TypeScript 4.1 开始,这些内部函数的代码直接使用 JavaScript 字符串运行时函数进行操作,并且无需了解区域设置。

1
2
3
4
5
6
7
8
9
function applyStringMapping(symbol: Symbol, str: string) {
switch (intrinsicTypeKinds.get(symbol.escapedName as string)) {
case IntrinsicTypeKind.Uppercase: return str.toUpperCase();
case IntrinsicTypeKind.Lowercase: return str.toLowerCase();
case IntrinsicTypeKind.Capitalize: return str.charAt(0).toUpperCase() + str.slice(1);
case IntrinsicTypeKind.Uncapitalize: return str.charAt(0).toLowerCase() + str.slice(1);
}
return str;
}

类成员

属性、方法、readonly、、getters/setters、索引签名

getters/setters

请注意,一个没有额外逻辑的字段支持的 get/set 对在JavaScript中很少有用。如果你不需要在get/set 操作中添加额外的逻辑,暴露公共字段也是可以的。

TypeScript对访问器有一些特殊的推理规则:

  • 如果存在 get ,但没有 set ,则该属性自动是只读的
  • 如果没有指定 setter 参数的类型,它将从 getter 的返回类型中推断出来
  • 访问器和设置器必须有相同的成员可见性

从TypeScript 4.3开始,可以有不同类型的访问器用于获取和设置

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
class Thing {
_size = 0;
get size(): number {
return this._size;
}
set size(value: string | number | boolean) {
let num = Number(value);
// 不允许NaN、Infinity等
if (!Number.isFinite(num)) {
this._size = 0;
return;
}
this._size = num;
}
}

访问器和设置器必须有相同的成员可见性?

在 TypeScript 中,访问器和设置器必须有相同的成员可见性。也就是说,如果您为一个成员定义了访问器或设置器,那么这个成员的访问级别必须与访问器或设置器中的访问级别相同。

例如,如果您为一个类的私有成员定义了访问器或设置器,那么这个访问器或设置器的访问级别也必须是私有的。同样,如果您为一个类的受保护成员定义了访问器或设置器,那么这个访问器或设置器的访问级别也必须是受保护的。

以下是一个 TypeScript 类的例子,其中访问器和设置器具有相同的成员可见性:

1
2
3
4
5
6
7
8
9
10
11
12
class MyClass {
private _value: number;

public get value(): number {
return this._value;
}

public set value(newValue: number) {
this._value = newValue;
}
}
// 这里的public一般省略

在这个例子中,MyClass 类定义了一个私有成员变量 _value,并为它定义了一个访问器 get value() 和一个设置器 set value(newValue: number)。由于 _value 是私有成员,因此访问器和设置器的访问级别也必须是私有的。

访问器和设置器的可见性要求确保了类的封装性和安全性,防止了对类的私有或受保护成员的直接访问。

构造函数(构造器)

在 TypeScript 中,类的构造函数可以接受参数,并且这些参数可以定义它们的类型。但是,类的构造函数不能有类型参数。

这是因为类型参数是在使用泛型时定义的,它们是在编译时确定的。而构造函数是在运行时调用的,因此在构造函数中定义类型参数是没有意义的。

如果您想在类中使用泛型,可以将泛型类型参数定义为类级别的类型参数,然后在类中的方法和属性中使用它们。例如:

1
2
3
4
5
6
7
8
9
10
11
class MyGenericClass<T> {
private value: T;

constructor(value: T) {
this.value = value;
}

getValue(): T {
return this.value;
}
}

在这个例子中,MyGenericClass 类接受一个类型参数 T,并在构造函数中接受一个类型为 T 的参数 value。然后,getValue 方法返回类型为 T 的值。

您可以实例化该类并将不同类型的值传递给构造函数:

1
2
3
4
5
const myStringClass = new MyGenericClass<string>('hello');
console.log(myStringClass.getValue()); // 输出 'hello'

const myNumberClass = new MyGenericClass<number>(42);
console.log(myNumberClass.getValue()); // 输出 42

构造函数不能有返回类型注释

在 TypeScript 中,构造函数不能有显式的返回类型注释,因为构造函数的返回值是由类本身确定的,而不是由构造函数的实现决定的。

当您使用 new 操作符实例化一个类时,构造函数会返回该类的实例,而且该实例的类型就是该类本身。因此,您不需要在构造函数中指定返回类型。

以下是一个 TypeScript 类的例子:

1
2
3
4
5
6
7
8
9
10
class MyClass {
constructor(private message: string) {}

showMessage() {
console.log(this.message);
}
}

const myInstance = new MyClass('Hello, world!');
myInstance.showMessage(); // 输出 'Hello, world!'

在这个例子中,MyClass 类的构造函数接受一个 message 参数,并将其保存在类的私有成员变量中。然后,类定义了一个 showMessage 方法,它输出 message 的值。

当我们使用 new 操作符创建一个 MyClass 实例时,不需要指定返回类型:

1
const myInstance = new MyClass('Hello, world!');

因为构造函数的返回值是 MyClass 类的实例,它的类型已经由类本身决定了。

类继承

implements

  • 继承接口,需要实现该接口的方法
  • 类也可以实现多个接口,逗号隔开,例如:class c implements A, B {

注意事项

implements 子句只是检查类是否可以被当作接口类型来对待。它根本不会改变类的类型或其方法。一个常见的错误来源是认为 implements 子句会改变类的类型–它不会!

1
2
3
4
5
6
7
8
9
interface Checkable {
check(name: string): boolean;
}
class NameChecker implements Checkable {
check(s) { // error 参数"s"隐式具有"any"类型
// any:注意这里没有错误
return s.toLowercse() === "ok";
}
}

在这个例子中,我们也许期望 s 的类型会受到 check 的 name: string 参数的影响。事实并非如此–实现子句并没有改变类主体的检查方式或其类型的推断。

同样地,实现一个带有可选属性的接口并不能创建该属性。

1
2
3
4
5
6
7
8
9
interface A {
x: number;
y?: number;
}
class C implements A {
x = 0;
}
const c = new C();
c.y = 10; // error 类型"c"上不存在属性"y"

extends

类可以从基类中扩展出来。派生类拥有其基类的所有属性和方法,也可以定义额外的成员。

重写方法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
class Base {
greet() {
console.log('Hello World');
}
}

class Derived extends Base {
greet(name?: string) {
if (name === undefined) {
super.greet();
} else {
console.log(name.toUpperCase());
}
}
}

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

派生类遵循其基类契约是很重要的。请记住,通过基类引用来引用派生类实例是非常常见的(而且总是合法的!)

1
2
3
4
// 通过基类引用对派生实例进行取别名
const b: Base = d;
// 没问题
b.greet();

如果 Derived 没有遵守Base的约定怎么办?

1
2
3
4
5
6
7
8
9
10
class Derived extends Base {
/**
* error
* 类型“Derived”中的属性“greet”不可分配给基类型“Base”中的同一属性。
* 不能将类型“(name: string) => void”分配给类型“() => void”。
*/
greet(name: string) {
console.log(name.toUpperCase());
}
}

如果我们不顾错误编译这段代码,这个样本就会崩溃:

1
2
3
const b: Base = new Derived();
// 崩溃,因为 "name"将是 undefined。
b.greet();

初始化顺序

让我们看看一下这段代码:

1
2
3
4
5
6
7
8
9
10
11
class Base {
name = "base";
constructor() {
console.log("My name is " + this.name);
}
}
class Derived extends Base {
name = "derived";
}
// 打印 "base", 而不是 "derived"
const d = new Derived();

这里发生了什么?

按照JavaScript的定义,类初始化的顺序是:

  • 基类的字段被初始化
  • 基类构造函数运行
  • 派生类的字段被初始化
  • 派生类构造函数运行

这意味着基类构造函数在自己的构造函数中看到了自己的name值,因为派生类的字段初始化还没有运行。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
class Base {
name = 'base';
constructor() {
console.log('My name is ' + this.name);
}
}

class Derived extends Base {
name = 'derived';

constructor() {
super();
console.log(this.name);
}
}

const d = new Derived();

输出

1
2
My name is base
derived

继承内置类型

注意:如果你不打算继承Array、Error、Map等内置类型,或者你的编译目标明确设置ES6/ES2015或以上,你可以跳过本节。

在ES2015中,返回对象的构造函数隐含地替代了 super(…) 的任何调用者的 this 的值。生成的构造函数代码有必要捕获 super(…) 的任何潜在返回值并将其替换为 this 。因此,子类化 Error 、 Array 等可能不再像预期那样工作。这是由于 Error 、 Array 等的构造函数使用ECMAScript 6的 new.target 来调整原型链;然而,在ECMAScript 5中调用构造函数时,没有办法确保 new.target 的值。其他的下级编译器一般默认有同样的限制。

instanceof 将在子类的实例和它们的实例之间被打破,所以 (new MsgError())instanceofMsgError 将返回 false

1
2
3
4
5
6
7
8
9
10
11
class MsgError extends Error {
constructor(m: string) {
super(m);
}
sayHello() {
return 'hello' + this.message
}
}

const msgError = new MsgError('hello');
console.log(msgError instanceof MsgError); // false

你可以在任何 super(…) 调用后立即手动调整原型。

1
2
3
4
5
6
7
8
9
10
11
12
13
class MsgError extends Error {
constructor(m: string) {
super(m);
// 明确地设置原型。
Object.setPrototypeOf(this, MsgError.prototype);
}
sayHello() {
return "hello " + this.message;
}
}

const msgError = new MsgError('hello');
console.log(msgError instanceof MsgError); // true

成员可见性

public

所有可访问

protected

只对他们所声明的子类可见,子类中可访问,子类实例后不可访问。

派生类需要遵循它们的基类契约,但可以选择公开具有更多能力的基类的子类型。这包括将受保护的成员变成公开。

1
2
3
4
5
6
7
8
9
class Base {
protected m = 10;
}
class Derived extends Base {
// 没有修饰符,所以默认为'公共'('public')
m = 15;
}
const d = new Derived();
console.log(d.m); // OK

private

派生类不可访问,子类实例后不可访问。

1
2
3
4
5
6
class Base {
private x = 0;
}
const b = new Base();
// 不能从类外访问
console.log(b.x);
1
2
3
4
5
6
7
8
9
10
class Base {
private x = 0;
}
const b = new Base();
class Derived extends Base {
showX() {
// 不能在子类中访问
console.log(this.x);
}
}

静态成员

类可以有静态成员。这些成员并不与类的特定实例相关联。它们可以通过类的构造函数对象本身来访问。

1
2
3
4
5
6
7
8
class MyClass {
static x = 0;
static printX() {
console.log(MyClass.x);
}
}
console.log(MyClass.x);
MyClass.printX();

静态成员也可以使用相同的 public 、 protected 和 private 可见性修饰符。

1
2
3
4
class MyClass {
private static x = 0;
}
console.log(MyClass.x);

静态成员也会被继承。

1
2
3
4
5
6
7
8
class Base {
static getGreeting() {
return "Hello world";
}
}
class Derived extends Base {
myGreeting = Derived.getGreeting();
}

特殊静态名称

一般来说,从函数原型覆盖属性是不安全的/不可能的。因为类本身就是可以用 new 调用的函数,所以某些静态名称不能使用。像 name 、 length 和 call 这样的函数属性,定义为静态成员是无效的。

1
2
3
class S {
static name = 's!'; // error 静态属性“name”与构造函数“S”的内置属性函数“name”冲突。
}

为什么没有静态类

TypeScript(和JavaScript)没有像C#和Java那样有一个叫做静态类的结构。

这些结构体的存在,只是因为这些语言强制所有的数据和函数都在一个类里面;因为这个限制在TypeScript中不存在,所以不需要它们。一个只有一个实例的类,在JavaScript/TypeScript中通常只是表示为一个普通的对象。

例如,我们不需要TypeScript中的 “静态类 “语法,因为一个普通的对象(甚至是顶级函数)也可以完成

这个工作。

1
2
3
4
5
6
7
8
9
10
// 不需要 "static" class
class MyStaticClass {
static doSomething() {}
}
// 首选 (备选 1)
function doSomething() {}
// 首选 (备选 2)
const MyHelperObject = {
dosomething() {}
};

类里的 static区块

静态块允许你写一串有自己作用域的语句,可以访问包含类中的私有字段。这意味着我们可以用写语句的所有能力来写初始化代码,不泄露变量,并能完全访问我们类的内部结构

1
2
3
4
5
6
7
8
9
10
11
12
13
14
class Foo {
static #count = 0;
get count() {
return Foo.#count;
}
static {
try {
const lastInstances = {
length: 100
};
Foo.#count += lastInstances.length;
} catch {}
}
}

类运行时中的this

TypeScript并没有改变JavaScript的运行时行为,而JavaScript的运行时行为偶尔很奇特。

1
2
3
4
5
6
7
8
9
10
11
12
13
class MyClass {
name = "MyClass";
getName() {
return this.name;
}
}
const c = new MyClass();
const obj = {
name: "obj",
getName: c.getName,
};
// 输出 "obj", 而不是 "MyClass"
console.log(obj.getName())

长话短说,默认情况下,函数内this的值取决于函数的调用方式。在这个例子中,因为函数是通过obj引用调用的,所以它的this值是obj而不是类实例。

箭头函数

如果你有一个经常会被调用的函数,失去了它的 this 上下文,那么使用一个箭头函数而不是方法定义是有意义的。

1
2
3
4
5
6
7
8
9
10
class MyClass {
name = "MyClass";
getName = () => {
return this.name;
};
}
const c = new MyClass();
const g = c.getName;
// 输出 "MyClass"
console.log(g());

这有一些权衡:

  • this 值保证在运行时是正确的,即使是没有经过TypeScript检查的代码也是如此。
  • 这将使用更多的内存,因为每个类实例将有它自己的副本,每个函数都是这样定义的。
  • 你不能在派生类中使用 super.getName ,因为在原型链中没有入口可以获取基类方法。

this 参数

在方法或函数定义中,一个名为 this 的初始参数在TypeScript中具有特殊的意义。这些参数在编译过程

中会被删除。

1
2
3
4
// 带有 "this" 参数的 TypeScript 输入
function fn(this: SomeType, x: number) {
/* ... */
}
1
2
3
4
// 编译后的JavaScript结果
function fn(x) {
/* ... */
}

TypeScript检查调用带有 this 参数的函数,是否在正确的上下文中进行。我们可以不使用箭头函数,而是在方法定义中添加一个 this 参数,以静态地确保方法被正确调用。

1
2
3
4
5
6
7
8
9
10
11
12
class MyClass {
name = "MyClass";
getName(this: MyClass) {
return this.name;
}
}
const c = new MyClass();
// 正确
c.getName();
// 错误
const g = c.getName;
console.log(g());

这种方法做出了与箭头函数方法相反的取舍:

  • JavaScript调用者仍然可能在不知不觉中错误地使用类方法

  • 每个类定义只有一个函数被分配,而不是每个类实例一个函数

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

this类型

在类中,一个叫做 this 的特殊类型动态地指向当前类的类型。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
class Box {
content: string = '';

set(value: string) {
this.content = value;
console.log('Box', this);
return this;
}
}

class ClearableBox extends Box {
clear() {
this.content = '';
}
}

const a = new ClearableBox();
const b = a.set('hello');
console.log(b);

export default {}

输出结果

1
2
Box ClearableBox { content: 'hello' }
ClearableBox { content: 'hello' }

Box类set方法的this指向了ClearableBox类。

参数类型注释中使用this,如果你有一个派生类,它的sameAs方法现在只接受该同一派生类的其他实例了。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
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();
const derivedc = new DerivedBox();
console.log(derived.sameAs(derivedc));
/**
* error
* 类型“Box”的参数不能赋给类型“DerivedBox”的参数。
* 类型 "Box" 中缺少属性 "otherContent",但类型 "DerivedBox" 中需要该属性。
*/
console.log(derived.sameAs(base));

基于类型守卫的this

常用的一个地方是允许对一个特定字段进行惰性验证。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
class Box<T> {
value?: T;

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

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

// (property) Box<unknown>.value?: unknownbox.value;
if (box.hasValue()) {
// (property) value: unk
}

参数属性

在构造函数中加入参数属性,可以简写了,不用在外面定义属性了。

1
2
3
4
5
6
7
8
9
10
11
12
13
class Params {
constructor(public readonly x: number, protected y: number, private z: number) {
this.x = x;
}
}
/**
* error
* 无法重新声明块范围变量“p”。
* 属性“y”受保护,只能在类“Params”及其子类中访问。
* 属性“z”为私有属性,只能在类“Params”中访问。
*/
const p = new Params(100, 300, 400);
console.log(p.x);

类表达式写法

1
2
3
4
5
6
7
8
9
10
const someClass = class<Type> {
content: Type;

constructor(value: Type) {
this.content = value;
}
};

const m = new someClass('hello');
console.log(m.content);

抽象类和成员

抽象类不能被实例化,即不能用new,需要常见派生类来实现抽象成员。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
abstract class Base {
abstract getName(): string;
printName() {
console.log("Hello, " + this.getName());
}
}

class Derived extends Base {
getName() {
return 'hello world'
}
}

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

抽象构造签名

有时候你想接受一些类的构造函数,产生一个从某些抽象类派生出来的类的实例。

可能想这样写:

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

Typescript正确地告诉你,你正试图实例化一个抽象类。毕竟,鉴于green的定义,写的这段代码是完全合法的,他最终会构造一个抽象类。

1
2
// bad
greet(Base)

相反,你想写一个函数,接受具有结构化签名的东西:

1
2
3
4
5
6
7
function greet(ctor: new () => Base) {
const instance = new ctor();
instance.printName();
}

greet(Derived);
// greet(Base);

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

1
2
# 类型“typeof Base”的参数不能赋给类型“new () => Base”的参数。
# 无法将抽象构造函数类型分配给非抽象构造函数类型。

类之间的关系

相同的两个类之间可以相互代替使用

1
2
3
4
5
6
7
8
9
10
class Point1 {
x = 0;
y = 0;
}
class Point2 {
x = 0;
y = 0;
}

const p: Point1 = new Point2();

包含关系的两个类之间也可以使用,小的类作为类型。这句话的理解:即使没有明确的继承,类之间的子类型关系也是存在的。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
class Point1 {
x = 0;
y = 0;
}
class Point2 {
x = 0;
y = 0;
z = 0;
}

// right
const p: Point1 = new Point2();
// error
const p: Point2 = new Point1();

下面这种情况会特殊点,空的类没有成员,在一个结构化类型系统中,一个没有成员的类型通常是其他任何类型的超类。

1
2
3
4
5
6
7
8
9
10
class Empty {

}
function fn(x: Empty) {
// 不能用x做任何事
}
// 以下均可调用
!fn(window);
fn({});
fn(fn);

模块

认识模块

主要考虑三个:

  • 语法:我想用什么语法来导入和导出?
  • 模块解析:模块名称(或路径)和磁盘上的文件之间是什么关系?
  • 模块输出目标:我编译出来的js模块应该是什么样子的?

额外的导入语法

示例的目录结构

1
2
3
+-- src
| --- export.ts
| --- index.ts

export.ts

1
2
export const pi = 3.14;
export default class RandomNumberGenerator {};

index.ts 引入 export.ts

写法一

1
2
3
4
import RNGen, { pi as π } from './export';

console.log(π);
const rnGen = new RNGen();

写法二

1
2
3
4
import * as math from './export';

console.log(math.pi);
const rnGen = new math.default();

TS特定的ES模块语法

如果是扩展类型和常规结合使用,写法如下:

示例的目录结构

1
2
3
+-- src
| --- export.ts
| --- index.ts

export.ts

1
2
3
4
5
6
7
8
9
export type Cat = {
breed: string;
yearOfBirth: number;
};
export interface Dog {
breed: string[];
yearOfBirth: number;
};
export const createCatName = () => '';

index.ts

如果只引入扩展类型

1
import { Cat, Dog } from './export';

或者

1
import type { Cat, Dog } from './export';

如果引入扩展类型和非扩展类型的其他东西,如函数

1
import { createCatName, type Cat, type Dog } from './export';

注意,引入export.ts时,是不带.ts的,因为编译后的是js,而非ts文件。

声明合并

介绍

TypeScript 中的一些独特概念在类型级别描述了 JavaScript 对象的形状。TypeScript 特别独特的一个例子是“声明合并”的概念。理解这个概念将使您在使用现有 JavaScript 时更有优势。它还为更高级的抽象概念打开了大门。

就本文而言,“声明合并”是指编译器将两个单独的同名声明合并为一个定义。这个合并的定义具有两个原始声明的特征。可以合并任意数量的声明;它不仅限于两个声明。

基本概念

在 TypeScript 中,声明至少在三组中的一组中创建实体:命名空间、类型或值。创建命名空间的声明创建了一个命名空间,其中包含使用点分符号访问的名称。类型创建声明就是这样做的:它们创建一个类型,该类型对声明的形状可见并绑定到给定的名称。最后,创建值的声明创建在输出 JavaScript 中可见的值。

Declaration Type Namespace Type Value
Namespace X X
Class X X
Enum X X
Interface X
Type Alias X
Function X
Variable X

了解每个声明创建的内容将帮助您了解执行声明合并时合并的内容。

合并接口

最简单,也许是最常见的声明合并类型是接口合并。在最基本的层面上,合并将两个声明的成员机械地连接到一个同名的接口中。

1
2
3
4
5
6
7
8
interface Box {
height: number;
width: number;
}
interface Box {
scale: number;
}
let box: Box = { height: 5, width: 6, scale: 10 };

接口的非函数成员应该是唯一的。如果它们不是唯一的,则它们必须属于同一类型。如果接口都声明了同名但类型不同的非函数成员,编译器将发出错误。

对于函数成员,每个同名的函数成员都被视为描述同一函数的重载。同样值得注意的是,在接口A与后来的接口A合并的情况下,第二个接口将比第一个接口具有更高的优先级

也就是说,在示例中:

1
2
3
4
5
6
7
8
9
10
interface Cloner {
clone(animal: Animal): Animal;
}
interface Cloner {
clone(animal: Sheep): Sheep;
}
interface Cloner {
clone(animal: Dog): Dog;
clone(animal: Cat): Cat;
}

这三个接口将合并以创建一个声明,如下所示:

1
2
3
4
5
6
interface Cloner {
clone(animal: Dog): Dog;
clone(animal: Cat): Cat;
clone(animal: Sheep): Sheep;
clone(animal: Animal): Animal;
}

请注意,每个组的元素都保持相同的顺序,但组本身是合并的,后来的重载组先排序。

此规则的一个例外是专用签名。如果签名的参数类型是单个字符串文字类型(例如,不是字符串文字的并集),那么它将冒泡到其合并的重载列表的顶部。

并集也称联合

例如,以下接口将合并在一起:

1
2
3
4
5
6
7
8
9
10
11
interface Document {
createElement(tagName: any): Element;
}
interface Document {
createElement(tagName: "div"): HTMLDivElement;
createElement(tagName: "span"): HTMLSpanElement;
}
interface Document {
createElement(tagName: string): HTMLElement;
createElement(tagName: "canvas"): HTMLCanvasElement;
}

生成的合并声明Document如下:

1
2
3
4
5
6
7
interface Document {
createElement(tagName: "canvas"): HTMLCanvasElement;
createElement(tagName: "div"): HTMLDivElement;
createElement(tagName: "span"): HTMLSpanElement;
createElement(tagName: string): HTMLElement;
createElement(tagName: any): Element;
}

合并命名空间

与接口类似,同名的命名空间也会合并它们的成员。由于命名空间同时创建命名空间和值,我们需要了解两者如何合并。

为了合并命名空间,每个命名空间中声明的导出接口的类型定义本身被合并,形成一个内部具有合并接口定义的命名空间。

要合并名称空间值,在每个声明站点,如果已存在具有给定名称的名称空间,则通过采用现有名称空间并将第二个名称空间的导出成员添加到第一个名称空间来进一步扩展它。

本例中的声明合并Animals

1
2
3
4
5
6
7
8
9
namespace Animals {
export class Zebra {}
}
namespace Animals {
export interface Legged {
numberOfLegs: number;
}
export class Dog {}
}

相当于:

1
2
3
4
5
6
7
namespace Animals {
export interface Legged {
numberOfLegs: number;
}
export class Zebra {}
export class Dog {}
}

这种名称空间合并模型是一个有用的起点,但我们还需要了解非导出成员会发生什么。非导出成员仅在原始(未合并的)命名空间中可见。这意味着合并后,来自其他声明的合并成员看不到非导出成员。

我们可以在这个例子中更清楚地看到这一点:

1
2
3
4
5
6
7
8
9
10
11
namespace Animal {
let haveMuscles = true;
export function animalsHaveMuscles() {
return haveMuscles;
}
}
namespace Animal {
export function doAnimalsHaveMuscles() {
return haveMuscles; // Error, because haveMuscles is not accessible here
}
}

因为haveMuscles未导出,所以只有animalsHaveMuscles共享相同未合并命名空间的函数才能看到该符号。该doAnimalsHaveMuscles函数,即使它是合并Animal命名空间的一部分,也看不到这个未导出的成员。

将命名空间与类、函数和枚举合并

命名空间足够灵活,可以与其他类型的声明合并。为此,名称空间声明必须跟在它将合并的声明之后。生成的声明具有两种声明类型的属性。TypeScript 使用此功能对 JavaScript 和其他编程语言中的某些模式进行建模。

将命名空间与类合并

这为用户提供了一种描述内部类的方法。

1
2
3
4
5
6
class Album {
label: Album.AlbumLabel;
}
namespace Album {
export class AlbumLabel {}
}

合并成员的可见性规则与合并命名空间部分中描述的相同,因此我们必须导出类AlbumLabel以供合并类查看。最终结果是在另一个类内部管理一个类。您还可以使用名称空间向现有类添加更多静态成员。

除了内部类模式之外,您可能还熟悉创建函数然后通过向函数添加属性来进一步扩展函数的 JavaScript 实践。TypeScript 使用声明合并以类型安全的方式构建这样的定义。

1
2
3
4
5
6
7
8
function buildLabel(name: string): string {
return buildLabel.prefix + name + buildLabel.suffix;
}
namespace buildLabel {
export let suffix = "";
export let prefix = "Hello, ";
}
console.log(buildLabel("Sam Smith"));

同样,命名空间可用于扩展具有静态成员的枚举:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
enum Color {
red = 1,
green = 2,
blue = 4,
}
namespace Color {
export function mixColor(colorName: string) {
if (colorName == "yellow") {
return Color.red + Color.green;
} else if (colorName == "white") {
return Color.red + Color.green + Color.blue;
} else if (colorName == "magenta") {
return Color.red + Color.blue;
} else if (colorName == "cyan") {
return Color.green + Color.blue;
}
}
}

不允许合并

并非所有合并都在 TypeScript 中被允许。目前,类不能与其他类或变量合并。有关模拟类合并的信息,请参阅TypeScript 中的混合部分。

模组扩充

尽管 JavaScript 模块不支持合并,但您可以通过导入然后更新现有对象来修补它们。让我们看一个玩具 Observable 示例:

1
2
3
4
5
6
7
8
9
// observable.ts
export class Observable<T> {
// ... implementation left as an exercise for the reader ...
}
// map.ts
import { Observable } from "./observable";
Observable.prototype.map = function (f) {
// ... another exercise for the reader
};

这在 TypeScript 中也能正常工作,但编译器不知道Observable.prototype.map. 您可以使用模块扩充将其告知编译器:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// observable.ts
export class Observable<T> {
// ... implementation left as an exercise for the reader ...
}
// map.ts
import { Observable } from "./observable";
declare module "./observable" {
interface Observable<T> {
map<U>(f: (x: T) => U): Observable<U>;
}
}
Observable.prototype.map = function (f) {
// ... another exercise for the reader
};
// consumer.ts
import { Observable } from "./observable";
import "./map";
let o: Observable<number>;
o.map((x) => x.toFixed());

import模块名称的解析方式与/中的模块说明符相同export。有关详细信息,请参阅模块。然后合并扩充中的声明,就好像它们是在与原始文件相同的文件中声明的一样。

但是,请记住两个限制:

  1. 您不能在扩充中声明新的顶级声明——只是对现有声明的补丁。
  2. 默认导出也不能被扩充,只能被命名为导出(因为您需要通过其导出名称来扩充导出,并且default是一个保留字 - 有关详细信息,请参见#14080

全局增强

您还可以从模块内部向全局范围添加声明:

1
2
3
4
5
6
7
8
9
10
11
12
// observable.ts
export class Observable<T> {
// ... still no implementation ...
}
declare global {
interface Array<T> {
toObservable(): Observable<T>;
}
}
Array.prototype.toObservable = function () {
// ...
};

全局扩充与模块扩充具有相同的行为和限制。

issues

创建一个对象会做哪三件事

js垃圾回收机制触发时机

ts编译为es5,class编译成了立即执行函数?

避免变量名别污染

静态方法中只能调用静态的方法或属性?

是的,跟非静态方法或属性是独立的,反之也是。

一个静态方法改变了某个静态属性,其他静态方法或类外部任何地方访问这个属性都会发生改变?

是。这个非静态方法,new object有区别。

静态属性或方法分配内存空间的时间早于对象空间的分配?

是。静态属性或方法分配内存空间会一直在,直到程序执行结束才被释放。