TypeScript-翻译

https://www.typescriptlang.org/docs/

https://www.typescriptlang.org/docs/

https://www.tslang.cn/docs/home.html#google_vignette

什么是 JavaScript?简史

JavaScript(也称为 ECMAScript)最初是一种简单的浏览器脚本语言。在它诞生之初,人们预期它仅用于嵌入网页中的简短代码片段——编写超过几十行的代码还相当罕见。因此,早期的浏览器执行这类代码的速度相当慢。然而,随着时间的推移,JS 变得越来越流行,Web 开发人员开始使用它来创建交互式体验。

为了应对 JavaScript 使用量的激增,Web 浏览器开发者优化了执行引擎(动态编译)并扩展了其功能(添加 API),这反过来又促使 Web 开发者更加广泛地使用 JavaScript。在现代网站上,你的浏览器经常运行着包含数十万行代码的应用程序。这就是“网络”漫长而渐进的发展历程:它最初只是一个简单的静态页面网络,如今已演变为一个能够运行各种类型丰富应用程序的平台。

不仅如此,JavaScript 已经流行到足以在浏览器之外使用,例如使用 Node.js 实现 JavaScript 服务器。JavaScript 的“随处可运行”特性使其成为跨平台开发的理想选择。如今,许多开发者使用JavaScript 来编写他们的整个技术栈!

总而言之,JavaScript 最初是为快速使用而设计的语言,后来发展成为编写数百万行代码应用程序的成熟工具。每种语言都有其自身的特性——一些奇特之处和意想不到的惊喜,而 JavaScript 的早期发展历程也使其拥有许多这样的特性。例如:

JavaScript 的相等运算符 ( ==)会强制转换其操作数,从而导致意想不到的行为:

js
if ("" == 0) {
  // 这是相等的
}
if (1 < x < 3) {
  // X是任何值都是true
}

JavaScript 还允许访问不存在的属性:

js
const obj = { width: 10, height: 15 };
// 由于拼写错误导致结果是NaN
const area = obj.width * obj.heigth;

大多数编程语言在出现这类错误时都会抛出错误,有些语言甚至会在编译期间——也就是代码运行之前——抛出错误。编写小型程序时,这类小问题虽然烦人但尚可解决;而编写包含成百上千行代码的应用程序时,这些不断出现的意外情况就成了严重的问题。

TypeScript:静态类型检查器

我们之前说过,有些编程语言根本不允许运行那些有缺陷的程序。在不运行代码的情况下检测代码错误称为静态检查。根据操作值的类型来判断什么是错误、什么不是错误称为静态类型检查。

TypeScript 会在程序执行前检查错误,并且基于值的种类进行检查,因此它是一个静态类型检查器。例如,上面的最后一个示例由于 <type>类型obj而出现错误。以下是 TypeScript 发现的错误:

image-20260104160405170

JavaScript 的类型化超集

那么,TypeScript 与 JavaScript 之间究竟有何关联呢?

TypeScript 不会因为 JavaScript 的语法而将其视为错误。这意味着你可以将任何可正常运行的 JavaScript 代码放入 TypeScript 文件中,而无需担心其具体的编写方式。

类型

然而,TypeScript 是一个类型化的超集,这意味着它添加了关于如何使用不同类型值的规则。之前的错误obj.heigth并非语法错误,而是以错误的方式使用了某种值(类型)。

再举一个例子,这是一段可以在浏览器中运行的 JavaScript 代码,它记录一个值:

ts
console.log(4 / []);

这个语法正确的程序会记录日志Infinity。但是,TypeScript 认为用数字除以数组是无意义的操作,并会抛出错误:

image-20260104160426798

如果您将一些代码从 JavaScript 文件移动到 TypeScript 文件,可能会遇到*类型错误,*具体取决于代码的编写方式。这些错误可能是代码本身存在的问题,也可能是 TypeScript 过于保守所致。本指南将演示如何添加各种 TypeScript 语法来消除此类错误。

运行时行为

TypeScript 也是一种能够保留JavaScript运行时行为的编程语言。TypeScript 的原则是绝不改变 JavaScript 代码的运行时行为。

这意味着,如果您将代码从 JavaScript 迁移到 TypeScript,即使 TypeScript 认为代码存在类型错误,也保证代码能够以相同的方式运行。

TypeScript 的一个基本承诺是保持与 JavaScript 相同的运行时行为,因为这意味着您可以轻松地在两种语言之间切换,而无需担心可能导致程序停止工作的细微差异。

已擦除的文字

简单来说,TypeScript 编译器完成代码检查后,会清除类型信息,生成最终的“编译后”代码。这意味着,代码编译完成后,生成的纯 JavaScript 代码将不包含任何类型信息。

这也意味着 TypeScript 永远不会根据它推断出的类型来改变程序的行为。简而言之,虽然你可能会在编译过程中看到类型错误,但类型系统本身对程序运行时的行为没有任何影响。

最后,TypeScript 不提供任何额外的运行时库。你的程序将使用与 JavaScript 程序相同的标准库(或外部库),因此无需学习任何额外的 TypeScript 特定框架。

基础

TypeScript 与 JavaScript 有着不同寻常的关系。TypeScript 提供了 JavaScript 的所有功能,并在这些功能之上添加了一层: TypeScript 的类型系统。

例如,JavaScript 提供了诸如 stringnumber 这样的原始类型,但它不检查你在赋值时与类型是否匹配。TypeScript 提供了这样的功能。

这意味着你现有的运行良好的 JavaScript 代码也是 TypeScript 代码。TypeScript 的主要好处是,它可以检查代码中的意外行为,从而降低出现错误的机会。

类型推断

TypeScript 可以识别 JavaScript 语言,在许多情况下可以推断类型。例如,在创建变量并将其赋值给特定值时, TypeScript 将使用该值作为其类型。

image-20260104102928515

通过感知 JavaScript 的工作原理,TypeScript 可以构建一个接受 JavaScript 代码但具有类型的类型系统。这个类型系统使得我们不需要添加额外的字符来显式地指定类型。在上面的例子中,TypeScript就是这样知道 helloWorldstring 类型的。

你可能已经在 Visual Studio Code 中编写了 JavaScript,并已使用了编辑器的自动补全功能。Visual Studio Code 使用了 TypeScript 的引擎,以便更容易地处理 JavaScript。

定义类型

你可以在 JavaScript 中使用各种各样的设计模式。然而,某些设计模式使得类型难以自动推断(例如,使用动态编程的模式)。为了使类型推断涵盖这些情况, TypeScript 支持扩展 JavaScript 语言,它可以让 TypeScript 知道如何去推断类型。

例如,要创建具有推断类型的对象,该类型包括 name: stringid: number,你可以这么写:

image-20260104105125764 你可以使用 interface 关键字声明显式地描述此对象的内部数据的类型

image-20260104105152856 然后你可以声明一个符合此接口(interface)的 JavaScript 对象,在变量声明后使用像 : TypeName 这样的语法:

image-20260104105249191 如果提供的对象与提供的接口不匹配,TypeScript 将警告:

image-20260104105312079 由于 JavaScript 支持类和面向对象编程,TypeScript 也支持。你可以将接口声明与类一起使用: image-20260104105341814 您可以使用接口对函数参数类型进行注释: image-20260104105441590JavaScript 中已经有一些基本类型:booleanbigintnullnumberstringsymbol undefined,它们都可以在接口中使用。

TypeScript 对此拓展,例如 anyunknownnevervoid

构建类型有两种语法: interface type。 你应该更喜欢 interface。当需要特定功能时使用 type

使用 TypeScript,可以通过组合简单类型来创建复杂类型。有两种流行的方法可以做到这一点:联合泛型

联合

使用联合,可以声明类型可以是许多类型中的一种。例如,可以将 boolean 类型描述为 truefalse

ts
type MyBool = true | false;

联合类型的一个流行用法是描述 string 或者 number 的字面量的合法值。

ts
type WindowStates = "open" | "closed" | "minimized";
type LockStates = "locked" | "unlocked";
type PositiveOddNumbersUnderTen = 1 | 3 | 5 | 7 | 9;

联合可以处理不同类型。例如,可能有一个函数处理 array 或者 string

ts
function getLength(obj: string | string[]) {
  return obj.length;
}

要了解变量的类型, 使用 typeof

类型推断语句
stringtypeof s === "string"
numbertypeof n === "number"
booleantypeof b === "boolean"
undefinedtypeof undefined === "undefined"
functiontypeof f === "function"
arrayArray.isArray(a)

例如,你可以使函数根据传递的是字符串还是数组返回不同的值: image-20260104160036022

泛型

泛型为类型提供变量。一个常见的例子是数组。没有泛型的数组可以包含任何内容。带有泛型的数组可以描述特定的值。

ts
type StringArray = Array<string>;
type NumberArray = Array<number>;
type ObjectWithNameArray = Array<{ name: string }>;

你可以声明自己使用泛型的类型: image-20260104160205443

结构化的类型系统

TypeScript 的一个核心原则是类型检查基于对象的属性和行为(type checking focuses on the shape that values have)。这有时被叫做“鸭子类型”或“结构类型”(structural typing)。

在结构化的类型系统当中,如果两个对象具有相同的结构,则认为它们是相同类型的。

ts
interface Point {
  x: number;
  y: number;
}
 
function logPoint(p: Point) {
  console.log(`${p.x}, ${p.y}`);
}
 
// 打印 "12, 26"
const point = { x: 12, y: 26 };
logPoint(point);

point 变量从未声明为 Point 类型。 但是,在类型检查中,TypeScript 将 point 的结构与 Point的结构进行比较。它们的结构相同,所以代码通过了。

结构匹配只需要匹配对象字段的子集。 image-20260104160229270类和对象确定结构的方式没有区别:

ts
interface Point {
  x: number;
  y: number;
}

function logPoint(p: Point) {
  console.log(`${p.x}, ${p.y}`);
}
// ---分割线---
class VirtualPoint {
  x: number;
  y: number;
 
  constructor(x: number, y: number) {
    this.x = x;
    this.y = y;
  }
}
 
const newVPoint = new VirtualPoint(13, 56);
logPoint(newVPoint); // 打印 "13, 56"

如果对象或类具有所有必需的属性,则 TypeScript 将表示是它们匹配的,而不关注其实现细节。

基础知识

JavaScript 中的每个值会随着我们执行不同的操作表现出一系列的行为。

这听起来很抽象,看下面的例子,考虑一下针对变量 message 可能执行的操作。

ts
// 访问 message 的 toLowerCase 方法并调用它
message.toLowerCase();

// 调用 message 函数
message();

如果我们拆分这个过程,那么第一行代码就是访问了 messagetoLowerCase 方法并调用它;

第二行代码则尝试直接调用 message 函数。

不过让我们假设一下,我们并不知道 message 的值 —— 这是很常见的一种情况,仅从上面的代码中我们无法确切得知最终的结果。 每个操作的结果完全取决于 message 的初始值。

假设 message 是这样定义的:

ts
const message = "Hello World!";

你可能很容易猜到,如果执行 message.toLowerCase(),我们将会得到一个所有字母都是小写的字符串。

如果执行第二行代码呢?

熟悉 JavaScript 的你肯定猜到了,这会抛出一个异常:

ts
TypeError: message is not a function

如果可以避免这样的错误就好了。

当我们执行代码的时候,JavaScript 运行时会计算出值的类型 —— 这种类型有什么行为和功能,从而决定采取什么措施。

这就是上面的代码会抛出 TypeError 的原因 —— 它表明字符串 "Hello World!" 无法作为函数被调用。

对于诸如 string 或者 number 这样的原始类型,我们可以通过 typeof 操作符在运行时计算出它们的类型。

但对于像函数这样的类型,并没有对应的运行时机制去计算类型。

举个例子,看下面的函数:

ts
function fn(x) {
  return x.flip();
}

从代码可以看出,仅当存在一个带有 flip 属性的对象时,这个函数才可以正常运行,但 JavaScript 无法在代码执行时以一种我们可以检查的方式传递这个信息。

JavaScript 只提供了动态类型 —— 执行代码,然后才能知道会发生什么事。

那么不妨采用一种替代方案,使用一个静态的类型系统,在代码实际执行前预测代码的行为。

静态类型检查

还记得之前我们将字符串作为函数调用时,抛出的 TypeError 错误吗?

当我们编写新代码的时候,我们也会尽量避免引入新的 bug。

静态类型系统描述了程序运行时值的结构和行为。

像 TypeScript 这样的静态类型检查器会利用类型系统提供的信息,并在事态发展不对劲的时候告知我们。

image-20260104204606568用 TypeScript 运行上一个例子,它会在我们执行代码之前首先抛出一个错误。

非异常错误

目前为止,我们讨论的都是运行时错误 —— JavaScript 运行时告诉我们,它觉得某个地方有异常。

这些异常之所以能够抛出,是因为 ECMAScript 规范 明确规定了针对异常应该表现的行为。

举个例子,规范指出,试图调用无法调用的东西应该抛出一个错误。

也许这听上去像是“显而易见的行为”,并且你会觉得,访问对象上不存在的属性时,也会抛出一个错误。

但恰恰相反,JavaScript 的表现和我们的预想不同,它返回的是值 undefined

js
const user = {
    name: 'Daniel',
    age: 26,
};
user.location;       // 返回 undefined

最终,我们需要一个静态类型系统来告诉我们,哪些代码在这个系统中被标记为错误的代码 —— 即使它是不会马上引起错误的“有效” JavaScript 代码。

在 TypeScript 中,下面的代码会抛出一个错误,指出 location 没有定义: image-20260104224159233虽然有时候这意味着你需要在表达的内容上进行权衡,但我们的目的是为了找到程序中更多合法的 bug。

而 TypeScript 也的确可以捕获到很多合法的 bug:

举个例子,拼写错误:

js
const announcement = "Hello World!";
 
// 你需要花多久才能注意到拼写错误?
announcement.toLocaleLowercase();
announcement.toLocalLowerCase();
 
// 实际上正确的拼写是这样的……
announcement.toLocaleLowerCase();

未调用的函数: image-20260104224217870 或者是基本的逻辑错误: image-20260104224229473

类型工具

TypeScript 可以在我们的代码出现错误时捕获 bug。

这很好,但更关键的是,它能够在一开始就防止我们的代码出现错误。

类型检查器可以通过获取的信息检查我们是否正在访问变量或者其它属性上的正确属性。

一旦它获取到了这些信息,它也能够提示你可能想要访问的属性。

这意味着 TypeScript 也能用于编辑代码。我们在编辑器中输入的时候,核心的类型检查器能够提供报错信息和代码补全。

image-20260104224312564

TypeScript 编译器 —— tsc

我们一直在讨论类型检查器,但目前为止还没上手使用过。

是时候和我们的新朋友 —— TypeScript 编译器 tsc 打交道了。

首先,通过 npm 进行安装。

bash
npm install -g typescript

这将全局安装 TypeScript 的编译器 tsc。如果你更倾向于安装在本地的 node_modules 文件夹中,那你可能需要借助 npx 或者类似的工具才能便捷地运行 tsc 指令。

现在,我们新建一个空文件夹,尝试编写第一个 TypeScript 程序:hello.ts

js
// 和世界打个招呼
console.log('Hello world!');

现在,让我们运行 typescript 安装包自带的 tsc 指令进行类型检查。

css
tsc hello.ts

如果我们查看当前目录,会发现除了 hello.ts 文件外还有一个 hello.js 文件。

hello.js 文件是 tsc 编译或者转换 hello.ts 文件之后输出的纯 JavaScript 文件。

如果检查 hello.js 文件的内容,我们可以看到 TypeScript 编译器处理完 .ts 文件后产出的内容:

js
// 和世界打个招呼
console.log('Hello world!');

在这个例子中,TypeScript 几乎没有需要转译的内容,所以转译前后的代码看起来一模一样。

编译器总是试图产出清晰可读的代码,这些代码看起来就像正常的开发者编写的一样。

如果我们刻意引入了一个类型检查错误呢?

我们重写一下 hello.ts

ts
// This is an industrial-grade general-purpose greeter function:
function greet(person, date) {
  console.log(`Hello ${person}, today is ${date}!`);
}
 
greet("Brendan");

如果我们再次执行 tsc hello.ts,那么会注意到命令行抛出了一个错误!

Expected 2 arguments, but got 1.

TypeScript 告诉我们,我们少传了一个参数给 greet 函数 —— 本来应该是要传入参数的。

报错时仍产出文件

有一件事你可能没有注意到,在上面的例子中,我们的 hello.js 文件再次发生了改动。

如果我们打开这个文件,会发现内容和输入的文件内容是一样的。

这可能有点出乎意料,毕竟 tsc 刚才报错了。但这种结果其实和 TypeScript 的核心原则有关:大多数时候,比 TypeScript 更了解代码。

当然,随着时间的推移,你可能希望对错误采取更具防御性的措施,同时也让 TypeScript 采取更加严格的行为。

在这种情况下,你可以开启 noEmitOnError 编译选项。

尝试修改你的 hello.ts 文件,并使用参数去运行 tsc 指令:

bash
tsc --noEmitOnError hello.ts

现在你会发现,hello.js 没有再发生改动了。

显式类型

我们修改一下代码,告诉 TypeScript person 是一个 stringdata 则应该是一个 Date 对象。

我们也会通过 date 去调用 toDateString 方法。

js
function greet(person: string, date: Date) {
  console.log(`Hello ${person}, today is ${date.toDateString()}!`);
}Try

我们所做的事情,是给 persondate 添加类型注解,描述 greet 调用的时候应该接受什么类型的参数。

有了类型注解之后,TypeScript 就能告诉我们,哪些情况下对于 greet 的调用可能是不正确的。

image-20260104224345659TypeScript 报错提示第二个参数有问题,但这是为什么呢?

你可能会有点惊讶,因为在 JavaScript 中直接调用 Date() 返回的是 string

另一方面,通过 new Date() 去构造一个 Date,则可以如预期那样返回一个 Date 对象。

不管怎样,我们可以快速修复这个错误:

js
function greet(person: string, date: Date) {
  console.log(`Hello ${person}, today is ${date.toDateString()}!`);
}
 
greet("Maddison", new Date());

记住,我们并不总是需要显式地进行类型注解。

在很多情况下,即使省略了类型注解,TypeScript 也可以为我们推断出类型。

image-20260104224405642

即使我们没有告诉 TypeScript msg 的类型是 string,它自己也能够搞清楚。

这是一个特性,在类型系统能够正确地进行类型推断的时候,最好不要手动添加类型注解了。

注意:代码信息会在上面的代码示例中展示出来。如果将鼠标放到变量上面,那么编辑器也会有相同的提示。

擦除类型

我们看一下,通过 tsc 将上面的 greet 函数编译成 JavaScript 后会发生什么事:

js
"use strict";
function greet(person, date) {
    console.log("Hello ".concat(person, ", today is ").concat(date.toDateString(), "!"));
}
greet("Maddison", new Date());

注意到有两个变化:

  1. 我们的 persondate 参数的类型注解不见了。
  2. 我们的“模板字符串” —— 使用反引号(`)包裹的字符串 —— 变成了通过 + 拼接的普通字符串。

稍后再解释第二点,我们先来看第一个变化。

类型注解并不属于 JavaScript(或者专业上所说的 ECMAScript)的内容,所以没有任何浏览器或者运行时能够直接执行不经处理的 TypeScript 代码。

这也是为什么 TypeScript 首先需要一个编译器 —— 它需要经过编译,才能去除或者转换 TypeScript 独有的代码,从而让这些代码可以在浏览器上运行。

降级

上面的另一个变化,就是我们的模板字符串从:

js
`Hello ${person}, today is ${date.toDateString()}!`;

被重写为:

js
"Hello " + person + ", today is " + date.toDateString() + "!";

为什么会这样子呢?

模板字符串是 ECMAScript 2015(或者 ECMAScript6、ES2015、ES6 等)引入的新特性。

TypeScript 可以将高版本 ECMAScript 的代码重写为类似 ECMAScript3 或者 ECMAScript5 (也就是 ES3 或者 ES5)这样较低版本的代码。

类似这样将更新或者“更高”版本的 ECMAScript 向下降级为更旧或者“更低”版本的代码,就是所谓的降级

默认情况下,TypeScript 会转化为 ES3 代码,这是一个非常旧的 ECMAScript 版本。我们可以使用 target 选项将代码往较新的 ECMAScript 版本转换。

通过使用 --target es2015 参数进行编译,我们可以得到 ECMAScript2015 版本的目标代码,这意味着这些代码能够在支持 ECMAScript2015 的环境中执行。

因此,运行 tsc --target es2015 hello.ts 之后,我们会得到如下代码:

js
function greet(person, date) {
  console.log(`Hello ${person}, today is ${date.toDateString()}!`);
}
greet("Maddison", new Date());

虽然默认的目标代码采用的是 ES3 语法,但现在浏览器大多数都已经支持 ES2015 了。

所以,大多数开发者可以安全地指定目标代码采用 ES2015 或者是更高的 ES 版本,除非你需要着重兼容某些古老的浏览器。

严格性设置

不同的用户会由于不同的理由去选择使用 TypeScript 的类型检查器。

一些用户寻求的是一种更加松散、可选的开发体验,他们希望类型检查仅作用于部分代码,同时还可享受 TypeScript 提供的功能。

这也是 TypeScript 默认提供的开发体验,类型是可选的,推断会使用最松散的类型,对于潜在的 null/undefined 类型的值也不会进行检查。

就像 tsc 在编译报错的情况下仍然能够正常产出文件一样,这些默认的配置会确保不对你的开发过程造成阻碍。

另一方面,大多数的用户更希望 TypeScript 可以快速地、尽可能多地检查代码,这也是这门语言提供了严格性设置的原因。

你越是调节它,TypeScript 就会为你检查越多东西。

TypeScript 有几个和类型检查相关的严格性设置,它们可以随时打开或关闭,如若没有特殊说明,我们文档中的例子都是在开启所有严格性设置的情况下执行的。

CLI 中的 strict 配置项,或者 tsconfig.json 中的 "strict: true" 配置项,可以一次性开启全部严格性设置。但我们也可以单独开启或者关闭某个设置。

在所有这些设置中,尤其需要关注的是 noImplicitAnystrictNullChecks

noImplicitAny

回想一下,在前面的某些例子中,TypeScript 没有为我们进行类型推断,这时候变量会采用最宽泛的类型:any。这并不是一件最糟糕的事情 —— 毕竟,使用 any 类型基本就和纯 JavaScript 一样了。

但是,使用 any 通常会和使用 TypeScript 的目的相违背。

启用 noImplicitAny 配置项,在遇到被隐式推断为 any 类型的变量时就会抛出一个错误。

strictNullChecks

默认情况下,nullundefined 可以被赋值给其它任意类型。

这会让你的编码更加容易,但世界上无数多的 bug 正是由于忘记处理 nullundefined 导致的

strictNullChecks配置项让处理 nullundefined 的过程更加明显,让我们不用担心自己是否忘记处理 nullundefined

常见类型

在本章中,我们将介绍一些在 JavaScript 代码中最常见的值的类型,并说明在 TypeScript 中描述这些类型相应的方法。 这不是一个详尽的列表,后续章节将描述命名和使用其他类型的更多方法。

我们将首先回顾一下你在编写 JavaScript 或 TypeScript 代码时可能遇到的最基本和最常见的类型。 这些将在稍后形成更复杂类型的核心构建块。

基本类型:stringnumber,和 boolean

JavaScript 拥有三种常用的基础类型:string、number 和 boolean。在 TypeScript 中,它们均有与之对应的同名类型。正如你所预料的,这些类型名称与你在 JavaScript 中对相关值执行 typeof 操作时看到的结果一致:

string 代表字符串值,例如 “Hello, world”。

number 用于处理数值,例如 42。由于 JavaScript 在运行时并不区分整数与浮点数,因此 TypeScript 也没有与 int 或 float 等效的独立类型——所有数值统一使用 number

boolean 则对应 true 和 false 这两个布尔值。

虽然在 TypeScript 中使用首字母大写的 StringNumberBoolean 也是合法的,但它们指向的是一些极少在代码中出现的特殊内置类型。在定义类型时,请务必坚持使用小写的 stringnumberboolean

Arrays

若要指定像 [1, 2, 3] 这样的数组类型,可以使用 number[] 语法。这种语法适用于任何类型(例如 string[] 表示字符串数组,依此类推)。

你可能还会看到 Array<number> 这种写法,其含义完全相同。关于 T<U> 这种语法,我们将在后续讨论“泛型”章节时进行深入介绍。

请注意,[number] 是完全不同的概念;具体请参阅关于“元组类型(Tuple Types)”的章节。

any

TypeScript 还提供了一个特殊的类型 any。当你希望某个特定的值不触发类型检查错误时,可以使用它。

当一个值的类型为 any 时,你可以访问它的任意属性(这些属性的类型同样会是 any)、将其作为函数调用、将其赋值给任何类型的变量(或从任何类型赋值给它),或者执行任何语法上合法的操作:

ts
let obj: any = { x: 0 };
// 以下代码行均不会触发编译器错误。
// 使用 `any` 会禁用后续所有的类型检查,
// 此时 TypeScript 假定你比它更了解代码的运行环境。
obj.foo();
obj();
obj.bar = 100;
obj = "hello";
const n: number = obj;

当你不想为了向 TypeScript 证明某行代码没问题而编写冗长的类型定义时,any 类型会非常有用。

noImplicitAny

当你未指定类型且 TypeScript 无法从上下文中推断出类型时,编译器通常会默认将其视为 any

然而,你通常应该尽量避免这种情况,因为 any 不受类型检查的约束。你可以使用编译器标志 noImplicitAny,将任何隐式的 any 标记为错误。

变量上的类型注解

当你使用 constvarlet 声明变量时,可以随选添加类型注解,以显式指定变量的类型:

ts
let myName: string = "Alice";

TypeScript 并不使用像 int x = 0; 这种“类型在左侧”风格的声明。类型注解始终位于被标注对象的后面

不过在大多数情况下,这并不是必需的。只要有可能,TypeScript 就会尝试自动推断代码中的类型。例如,变量的类型会根据其初始值的类型进行推断:

ts
// 不需要类型注解 —— 'myName' 被推断为 'string' 类型
let myName = "Alice";

函数(Functions)

在 JavaScript 中,函数是传递数据的主要方式。TypeScript 允许你指定函数输入值(参数)和输出值(返回值)的类型。

参数类型注解

声明函数时,可以在每个参数后面添加类型注解,以声明该函数接受的参数类型。参数类型注解紧跟在参数名之后:

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

当参数拥有类型注解时,传入该函数的参数将被检查:

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

即使参数没有类型注解,TypeScript 仍然会检查你是否传递了正确数量的参数。

返回值类型注解

你也可以添加返回值类型注解。返回值类型注解出现在参数列表之后:

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

与变量类型注解类似,你通常不需要显式标注返回值类型,因为 TypeScript 会根据 return 语句自动推断函数的返回类型。在上面的示例中,添加类型注解并不会改变任何实质内容。但在某些代码库中,开发者会出于文档记录、防止意外更改或个人偏好等原因显式指定返回类型。

匿名函数

匿名函数与函数声明略有不同。当一个函数出现在 TypeScript 可以确定其调用方式的地方时,该函数的参数会被自动赋予类型。

ts
// 这里没有类型注解,但 TypeScript 能够发现错误
const names = ["Alice", "Bob", "Eve"];
 
// 函数的上下文类型推断
names.forEach(function (s) {
  console.log(s.toUppercase());
  // 属性“toUppercase”在类型“string”上不存在。你是否是指“toUpperCase”?
});
 
// 上下文类型推断也适用于箭头函数
names.forEach((s) => {
  console.log(s.toUppercase());
  // 属性“toUppercase”在类型“string”上不存在。你是否是指“toUpperCase”?
});

尽管参数 s 没有类型注解,但 TypeScript 利用 forEach 函数的类型定义以及数组的推断类型,确定了 s 的类型。

这一过程被称为上下文类型推断(Contextual Typing),因为函数所处的“上下文”告知了它应该具备的类型。

对象类型(Object Types)

除了基础类型外,最常见的类型就是对象类型。这涵盖了任何带有属性的 JavaScript 值,而事实上几乎所有值都符合这一特征。定义对象类型时,只需列出其属性名称及其对应的类型。

例如,以下函数接受一个类似于坐标点的对象:

ts
// 参数的类型注解是一个对象类型
function printCoord(pt: { x: number; y: number }) {
  console.log("坐标的 x 值为 " + pt.x);
  console.log("坐标的 y 值为 " + pt.y);
}
printCoord({ x: 3, y: 7 });

在上面的例子中,我们为参数标注了包含 xy 两个属性的类型,且两者的类型均为 number。你可以使用 ,; 来分隔属性,且最后一个分隔符是可选的。

每个属性的类型部分也是可选的。如果你不指定类型,它将被默认为 any

可选属性

对象类型还可以指定部分或全部属性为可选。只需在属性名后面添加一个 ? 即可:

ts
function printName(obj: { first: string; last?: string }) {
  // ...
}
// 以下两种写法均正确
printName({ first: "Bob" });
printName({ first: "Alice", last: "Alisson" });

在 JavaScript 中,如果你访问一个不存在的属性,会得到 undefined 而非触发运行时错误。因此,当你读取一个可选属性时,在使用它之前必须先检查是否为 undefinedimage-20260104224549567

联合类型 (Union Types)

TypeScript 的类型系统允许你使用多种运算符,基于现有类型构建新类型。既然我们已经掌握了基本类型的写法,现在是时候开始以有趣的方式组合它们了。

定义联合类型

你首先会接触到的组合类型方式是联合类型。联合类型是由两个或多个其他类型组成的类型,表示值可以是这些类型中的任意一种。我们将这些类型中的每一个都称为该联合类型的成员(members)

让我们编写一个可以处理字符串或数字的函数:

ts
function printId(id: number | string) {
  console.log("Your ID is: " + id);
}
// OK
printId(101);
// OK
printId("202");
// Error
printId({ myID: 22342 });
// 类型“{ myID: number; }”的参数不能赋给类型“string | number”的参数。

image-20260104224606410

使用联合类型 (Working with Union Types)

提供一个符合联合类型的值非常简单——只需提供一个符合该联合类型任一成员的类型即可。如果你拥有一个联合类型的值,该如何使用它呢?

TypeScript 仅允许你对该联合类型执行对每一个成员都有效的操作。例如,如果你有一个 string | number 类型的联合类型,你不能使用仅在 string 上可用的方法:

image-20260104224622522

解决方案是用代码缩小(Narrow)联合类型的范围,就像你在没有类型注解的 JavaScript 中所做的那样。当 TypeScript 能够根据代码结构为某个值推断出更具体的类型时,就会发生类型缩小。

例如,TypeScript 知道只有字符串值在执行 typeof 时才会得到 "string"

ts
function printId(id: number | string) {
  if (typeof id === "string") {
    // In this branch, id is of type 'string'
    console.log(id.toUpperCase());
  } else {
    // Here, id is of type 'number'
    console.log(id);
  }
}

另一个例子是使用像 Array.isArray 这样的函数:

ts
function welcomePeople(x: string[] | string) {
  if (Array.isArray(x)) {
    // Here: 'x' is 'string[]'
    console.log("Hello, " + x.join(" and "));
  } else {
    // Here: 'x' is 'string'
    console.log("Welcome lone traveler " + x);
  }
}

注意,在 else 分支中,我们不需要做任何特殊处理——如果 x 不是 string[],那么它一定是一个 string

有时你会遇到一个所有成员都有共同点的联合类型。例如,数组和字符串都有 slice 方法。如果联合类型中的每个成员都有一个共同的属性,你可以直接使用该属性而无需进行类型缩小:

ts
// Return type is inferred as number[] | string
function getFirstThree(x: number[] | string) {
  return x.slice(0, 3);
}

类型别名

我们通过直接在类型注解中编写对象类型和联合类型来使用它们。 这很方便,但是常常会想要多次使用同一个类型,并且通过一个名称引用它。

类型别名的语法是:

ts
type 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);
}
 
printCoord({ x: 100, y: 100 });Try

实际上,不只是对象类型,你可以使用类型别名为任何类型命名。 例如,类型别名可以命名联合类型:

ts
type ID = number | string;Try

请注意,别名 只是 别名 - 你不能使用类型别名创建同一类型的不同“版本”。 当你使用别名时,它与您编写的别名类型完全一样。 换句话说,这段代码 看起来 可能是非法的,但是对于 TypeScript 来说是正确的,因为这两种类型都是同一类型的别名:

ts
declare function getInput(): string;
declare function sanitize(str: string): string;
// ---分割---
type UserInputSanitizedString = string;
 
function sanitizeInput(str: string): UserInputSanitizedString {
  return sanitize(str);
}
 
// 创建一个经过清理的输入框
let userInput = sanitizeInput(getInput());
 
// 仍然可以使用字符串重新赋值
userInput = "new input";

declare 语法是 TypeScript (TS) 特有的,它在编译为 JavaScript (JS) 后会被完全移除。

declare 用于类型声明(Type Declarations)。它的作用是告诉 TypeScript 编译器:“某个变量、函数或类已经在其他地方(比如外部 JS 库或运行环境)存在了,你只需要知道它的类型即可,不需要在编译时生成代码。”

常见场景

  • 使用全局变量:比如在浏览器环境中使用 window 之外的全局变量。
  • 编写类型定义文件 (.d.ts):为没有类型提示的第三方 JS 库手动补充类型。
  • 引入外部资源:例如 declare module "*.vue",让 TS 识别非代码文件。

编译对比

TypeScript 源码:

ts
declare var myLibrary: any;
myLibrary.doSomething();

编译后的 JavaScript:

ts
// declare 语句消失了,只剩下调用代码
myLibrary.doSomething();

接口

接口声明是命名对象类型的另一种方式:

ts
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);
}
 
printCoord({ x: 100, y: 100 });Try

就像我们上面使用类型别名时一样,这个示例的工作方式就像我们使用了匿名对象类型一样。 TypeScript 只关心我们传递给 printCoord 的值的结构 - 它只关心它是否具有预期的属性。

类型别名和接口之间的区别

类型别名和接口非常相似,在大多数情况下你可以在它们之间自由选择。 几乎所有的 interface 功能都可以在 type 中使用,关键区别在于不能重新开放类型以添加新的属性,而接口始终是可扩展的

image-20260104224650200image-20260104224700742

  • 类型别名不能参与 声明合并,但接口可以。
  • 接口只能用于 声明对象的形状,不能重命名基本类型.

在大多数情况下,你可以根据个人喜好进行选择,TypeScript 会告诉你它是否需要其他类型的声明。如果您想要启发式方法,可以使用 interface 直到你需要使用 type 中的功能。

类型断言 (Type Assertions)

有时你会比 TypeScript 更了解某个值的类型信息。

例如,如果你正在使用 document.getElementById,TypeScript 只知道这将返回某种 HTMLElement,但你可能清楚你的页面中某个特定 ID 的元素始终会是一个 HTMLCanvasElement

在这种情况下,你可以使用类型断言来指定一个更具体的类型:

ts
const myCanvas = document.getElementById("main_canvas") as HTMLCanvasElement;

与类型注解一样,类型断言会被编译器移除,并且不会影响代码的运行时行为。

你也可以使用尖括号语法(除非代码在 .tsx 文件中),其效果是等价的:

ts
const myCanvas = <HTMLCanvasElement>document.getElementById("main_canvas");

TypeScript 只允许将类型断言转换为类型的更具体更不具体的版本。这条规则可以防止“不可能”的强制转换,例如:

image-20260104224718936

有时,这条规则可能过于保守,会禁止一些可能有效但更为复杂的转换。如果发生这种情况,你可以使用两次断言:首先断言为 any(或我们稍后将介绍的 unknown),然后再断言为目标类型:

ts
const a = expr as any as T;

字面量类型 (Literal Types)

除了通用的 stringnumber 类型外,我们还可以在类型位置引用具体的字符串和数值。

理解这一点的一种方式是思考 JavaScript 声明变量的不同方式。varlet 都允许修改变量中保存的内容,而 const 则不允许。这种差异反映在 TypeScript 如何为字面量创建类型上。

image-20260104224734704

就其本身而言,字面量类型并没有太大价值:

image-20260104224745668

变量只能拥有一个值并没有什么用处!

但通过将字面量组合成联合类型,你可以表达一个更有用的概念——例如,只接受一组特定已知值的函数:

image-20260104224756161

数字字面量类型的运作方式与之相同:

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

当然,你也可以将这些字面量与非字面量类型进行组合:

image-20260104224807559

字面量推断 (Literal Inference)

当你使用对象初始化变量时,TypeScript 会假设该对象的属性值随后可能会发生变化。例如,如果你编写如下代码:

ts
const obj = { counter: 0 };
if (someCondition) {
  obj.counter = 1;
}

TypeScript 不会认为将之前为 0 的字段赋值为 1 是一个错误。另一种说法是 obj.counter 的类型必须是 number 而非 0,因为类型被用于判定“读取”和“写入”两种行为。

同样的情况也适用于字符串:

image-20260104224819140

在上述示例中,req.method 被推断为 string 而非 "GET"。因为在 req 的创建与 handleRequest 的调用之间,代码可能会被执行,并可能将一个像 "GUESS" 这样的新字符串赋值给 req.method,所以 TypeScript 认为这段代码存在错误。

有两种方法可以解决这个问题。

  1. 你可以通过在任一位置添加类型断言来改变推断结果:

    ts
    // 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"”。

  2. 你可以使用 as const 将整个对象转换为字面量类型:

    ts
    const req = { url: "https://example.com", method: "GET" } as const;
    handleRequest(req.url, req.method);Try

as const 后缀的行为类似于 const,但它是针对类型系统的,它能确保所有属性都被赋予字面量类型,而不是像 stringnumber 这样更通用的版本。

null and undefined

JavaScript 有两个用于表示值缺失或未初始化的原始值:nullundefined

TypeScript 有两个对应的同名类型。这些类型的行为取决于你是否启用了 strictNullChecks 选项。

strictNullChecks off

在关闭 strictNullChecks 的情况下,仍然可以正常访问可能为 nullundefined 的值,并且可以将 nullundefined 赋值给任何类型的属性。这与没有空值检查的语言(如 C#、Java)的行为类似。缺乏对这些值的检查通常是错误的主要来源;如果代码库具备可行性,我们始终建议开启 strictNullChecks

strictNullChecks on

在开启 strictNullChecks 的情况下,当一个值为 nullundefined 时,你在使用该值的逻辑或属性之前需要先对这些值进行测试。就像在使用可选属性前检查其是否为 undefined 一样,我们可以通过类型缩小(narrowing)来检查可能为 null 的值:

ts
function doSomething(x: string | null) {
  if (x === null) {
    // do nothing
  } else {
    console.log("Hello, " + x.toUpperCase());
  }
}Try

非空断言操作符(后缀!

TypeScript 还提供了一种特殊的语法,可以在不进行任何显式检查的情况下,从类型中移除 nullundefined。在任何表达式后面写下 !,实际上就是进行一次类型断言,断言该值不是 nullundefined

ts
function liveDangerously(x?: number | null) {
  // 没有错误
  console.log(x!.toFixed());
}

与其他类型断言一样,这不会改变代码的运行时行为,因此务必只在你知道该值不可能nullundefined 时才使用 !

枚举 (Enums)

枚举是 TypeScript 为 JavaScript 添加的一项特性,它允许描述一个可能是命名常量集合中的某一个值。与大多数 TypeScript 特性不同,这不仅仅是在类型层面对 JavaScript 的补充,而是被添加到语言和运行时的实体。正因如此,这是一项你应该了解其存在、但在确定需要之前可能要暂缓使用的特性。你可以在枚举参考页面阅读更多相关内容.。

不常用的原始类型 (Less Common Primitives)

值得一提的是,JavaScript 中的其余原始类型在类型系统中也都有所体现。不过我们在这里不会展开深入讨论。

bigint

From ES2020 onwards, there is a primitive in JavaScript used for very large integers, BigInt:

ts
// Creating a bigint via the BigInt function
const oneHundred: bigint = BigInt(100);
 
// Creating a BigInt via the literal syntax
const anotherHundred: bigint = 100n;

You can learn more about BigInt in the TypeScript 3.2 release notes.

symbol

JavaScript 中有一个原始类型,通过 Symbol() 函数调用来创建全局唯一的引用:

image-20260104224847180

You can learn more about them in Symbols reference page.

类型缩小 (Narrowing)

想象我们有一个名为 padLeft 的函数。

ts
function padLeft(padding: number | string, input: string): string {
  throw new Error("Not implemented yet!");
}

如果 padding 是数字,它将把该数字视为我们想要在 input 前面添加的空格数量。如果 padding 是字符串,它应该直接将 padding 添加到 input 的前面。让我们尝试实现当 padLeftpadding 参数被传入数字时的逻辑。image-20260104224935018

糟糕,我们在 padding 上遇到了一个错误。TypeScript 正在警告我们将一个类型为 number | string 的值传递给了只接受 number 类型的 repeat 函数,事实也确实如此。换句话说,我们既没有首先明确检查 padding 是否为一个数字,也没有处理它是字符串的情况,所以让我们按照这个思路来做。

ts
function padLeft(padding: number | string, input: string): string {
  if (typeof padding === "number") {
    return " ".repeat(padding) + input;
  }
  return padding + input;
}

如果这看起来大多像无趣的 JavaScript 代码,那正是其精髓所在。除了我们添加的类型注解之外,这段 TypeScript 代码看起来就像 JavaScript。其理念在于,TypeScript 的类型系统旨在尽可能轻松地编写典型的 JavaScript 代码,而无需为了获得类型安全而大费周章。

虽然表面上看起来没什么,但底层其实做了很多工作。正如 TypeScript 使用静态类型分析运行时值一样,它也将类型分析覆盖在 JavaScript 的运行时控制流结构上,比如 if/else、三元运算、循环、真值检查等,这些都会影响类型。

在我们的 if 检查中,TypeScript 看到 typeof padding === "number",并将其理解为一种称为类型守卫(type guard)的特殊代码形式。TypeScript 会跟踪程序可能采取的执行路径,以分析值在给定位置上可能具备的最具体类型。它通过查看这些特殊的检查(即类型守卫)和赋值语句,将类型精炼为比声明时更具体的类型的过程,被称为缩小(narrowing)。在许多编辑器中,我们可以观察到这些类型随位置变化的过程,我们也会在示例中这样做。

image-20260104225004005

TypeScript 能够理解几种不同的结构来进行类型缩小(narrowing)。

typeof 类型守卫

正如我们所见,JavaScript 支持 typeof 操作符,它可以提供运行时值的基本类型信息。TypeScript 期望该操作符返回一组特定的字符串:

  • "string"
  • "number"
  • "bigint"
  • "boolean"
  • "symbol"
  • "undefined"
  • "object"
  • "function"

就像我们在 padLeft 中看到的那样,这个操作符在许多 JavaScript 库中非常常见,TypeScript 可以理解它并在不同的分支中缩小类型。

在 TypeScript 中,针对 typeof 返回值进行的检查就是一种类型守卫。由于 TypeScript 编码了 typeof 对不同值的操作方式,它也了解该操作符在 JavaScript 中的一些怪异行为。例如,注意在上面的列表中,typeof 并不返回字符串 "null"。请看以下示例:

image-20260104225015294

printAll 函数中,我们尝试检查 strs 是否为对象,以判断它是否为数组类型(现在是强化“在 JavaScript 中数组是对象类型”这一概念的好时机)。但事实证明,在 JavaScript 中,typeof null 实际上也是 "object"!这是历史上不幸的意外之一。

经验丰富的用户可能对此并不感到惊讶,但并非每个人都在 JavaScript 中遇到过这个坑;幸运的是,TypeScript 会告知我们 strs 仅被缩小到了 string[] | null,而不是 string[]

这或许可以顺势引出我们所谓的“真值(truthiness)”检查。

真值缩小 (Truthiness narrowing)

“真值(Truthiness)”可能不是一个你在词典里能找到的单词,但在 JavaScript 中它是一个经常被提及的概念。

在 JavaScript 中,我们可以在条件语句、&&||if 语句、布尔取反 (!) 等处使用任何表达式。例如,if 语句并不要求其条件必须始终为 boolean 类型。

function getUsersOnlineMessage(numUsersOnline: number) {
  if (numUsersOnline) {
    return `There are ${numUsersOnline} online now!`;
  }
  return "Nobody's here. :(";
}Try

在 JavaScript 中,诸如 if 之类的结构会先将其条件“强制转换(coerce)”为布尔值以使其有意义,然后根据结果是 true 还是 false 来选择分支。像以下这些值:

  • 0
  • NaN
  • "" (the empty string)
  • 0n (the bigint version of zero)
  • null
  • undefined

这些值都会被强制转换为 false,而其他值则会被强制转换为 true。你始终可以通过 Boolean 函数运行值,或者使用较短的双重布尔取反来将值强制转换为布尔值。(后者的优势在于 TypeScript 会推断出一个具体的字面量布尔类型 true,而前者推断出的类型为 boolean)。

image-20260104225026859

利用这种行为非常流行,特别是为了防范 nullundefined 等值。作为示例,让我们尝试将其用于我们的 printAll 函数。

function printAll(strs: string | string[] | null) {
  if (strs && typeof strs === "object") {
    for (const s of strs) {
      console.log(s);
    }
  } else if (typeof strs === "string") {
    console.log(strs);
  }
}Try

你会注意到,通过检查 strs 是否为真值,我们消除了上面的错误。这至少能防止我们在运行代码时出现如下可怕的错误:

TypeError: null is not iterable

不过要记住,对原始类型进行真值检查通常容易出错。例如,考虑另一种编写 printAll 的尝试:

function printAll(strs: string | string[] | null) {
  // !!!!!!!!!!!!!!!!
  //  DON'T DO THIS!
  //   KEEP READING
  // !!!!!!!!!!!!!!!!
  if (strs) {
    if (typeof strs === "object") {
      for (const s of strs) {
        console.log(s);
      }
    } else if (typeof strs === "string") {
      console.log(strs);
    }
  }
}Try

我们把整个函数体包装在一个真值检查中,但这有一个微妙的缺点:我们可能不再能正确处理空字符串的情况。

TypeScript 在这里完全不会报错,但如果你对 JavaScript 不太熟悉,这种行为值得注意。TypeScript 通常可以帮助你尽早发现 bug,但如果你选择不对某个值做任何处理,在不过度限制的前提下,它能做的也有限。如果你愿意,可以通过 linter(代码检查工具)来确保处理这类情况。

关于通过真值进行类型缩小的最后一点是,使用 ! 进行布尔取反会从取反的分支中过滤掉相应类型。

function multiplyAll(
  values: number[] | undefined,
  factor: number
): number[] | undefined {
  if (!values) {
    return values;
  } else {
    return values.map((x) => x * factor);
  }
}Try

相等缩小 (Equality narrowing)

TypeScript 还会使用 switch 语句以及相等性检查(如 ===!====!=)来缩小类型。例如:

image-20260104225043477

当我们如上例所示检查 xy 是否相等时,TypeScript 知道它们的类型也必须相等。由于 stringxy 都能取得的唯一共同类型,因此 TypeScript 知道在第一个分支中 xy 必须都是字符串。

针对特定的字面量值(而不是变量)进行检查同样有效。在关于真值缩小的章节中,我们编写了一个容易出错的 printAll 函数,因为它意外地没有正确处理空字符串。相反,我们可以进行特定的检查来排除 null,TypeScript 依然能正确地从 strs 的类型中移除 null

image-20260104225053487

当我们如上例所示检查 xy 是否相等时,TypeScript 知道它们的类型也必须相等。由于 stringxy 都能取得的唯一共同类型,因此 TypeScript 知道在第一个分支中 xy 必须都是字符串。

针对特定的字面量值(而不是变量)进行检查同样有效。在关于真值缩小的章节中,我们编写了一个容易出错的 printAll 函数,因为它意外地没有正确处理空字符串。相反,我们可以进行特定的检查来排除 null,TypeScript 依然能从 strs 的类型中正确地移除 nullimage-20260104225106897

in 操作符缩小 (The in operator narrowing)

JavaScript 有一个操作符用于确定对象及其原型链是否具有特定名称的属性:in 操作符。TypeScript 将此视为缩小潜在类型的一种方式。

例如,在代码 "value" in x 中,"value" 是一个字符串字面量,而 x 是一个联合类型。 “true” 分支会缩小 x 的类型,使其仅包含具有可选或必选属性 value 的类型;而 “false” 分支则缩小为具有可选属性或不具备该属性的类型。

type Fish = { swim: () => void };
type Bird = { fly: () => void };
 
function move(animal: Fish | Bird) {
  if ("swim" in animal) {
    return animal.swim();
  }
 
  return animal.fly();
}Try

重申一下,对于类型缩小,可选属性会同时存在于两侧。例如,人类(在有合适装备的情况下)既可以游泳也可以飞,因此应该出现在 in 检查的两侧:

image-20260104225120255

instanceof 缩小

JavaScript 有一个操作符用于检查一个值是否是另一个值的“实例”。更具体地说,在 JavaScript 中,x instanceof Foo 会检查 x 的原型链是否包含 Foo.prototype。虽然我们在这里不会深入探讨(当你学习类时会看到更多相关内容),但对于大多数可以用 new 构造的值,它仍然非常有用。正如你可能已经猜到的,instanceof 也是一个类型守卫,TypeScript 会在受 instanceof 保护的分支中缩小类型。

image-20260104225130678

赋值语句

正如我们早些时候提到的,当我们为任何变量赋值时,TypeScript 会查看赋值语句的右侧,并相应地缩小左侧的类型。

image-20260104225142861

请注意,这些赋值中的每一个都是有效的。尽管在第一次赋值后,观察到的 x 类型变成了 number,但我们仍然能够将 string 赋值给 x。这是因为 x声明类型(即 x 初始时的类型)是 string | number,而可赋值性始终是根据声明类型进行检查的。

如果我们当时将 boolean 赋值给 x,就会看到一个错误,因为那不属于声明类型的一部分。

image-20260104225154195

控制流分析

到目前为止,我们已经看了一些关于 TypeScript 如何在特定分支内进行缩小的基础示例。但除了单纯地从每个变量出发并在 ifwhile、条件语句等结构中寻找类型守卫之外,底层发生的事情还有很多。例如:

function padLeft(padding: number | string, input: string) {
  if (typeof padding === "number") {
    return " ".repeat(padding) + input;
  }
  return padding + input;
}Try

padLeft 在其第一个 if 块中执行了返回。TypeScript 能够分析这段代码并发现,在 padding 为数字的情况下,函数体的剩余部分(return padding + input;)是不可达的。因此,它能够为函数的其余部分将 numberpadding 的类型中移除(将其从 string | number 缩小为 string)。

这种基于可达性对代码进行的分析被称为控制流分析 (control flow analysis)。TypeScript 使用这种流分析在遇到类型守卫和赋值语句时缩小类型。当一个变量被分析时,控制流可以不断地拆分和重新合并,该变量在每个点都可以被观察到具有不同的类型。

image-20260104225209763

使用类型谓词 (Using type predicates)

到目前为止,我们一直利用现有的 JavaScript 构造来处理类型缩小,但有时你可能希望更直接地控制类型在代码中的变化方式。

为了定义用户自定义的类型守卫(user-defined type guard),我们只需要定义一个返回值类型为**类型谓词(type predicate)**的函数:

function isFish(pet: Fish | Bird): pet is Fish {
  return (pet as Fish).swim !== undefined;
}Try

在此示例中,pet is Fish 就是我们的类型谓词。谓词的形式为 parameterName is Type,其中 parameterName 必须是当前函数签名中的参数名称。

每当使用某个变量调用 isFish 时,如果原始类型是兼容的,TypeScript 就会将该变量缩小到该特定类型。

// Both calls to 'swim' and 'fly' are now okay.
let pet = getSmallPet();
 
if (isFish(pet)) {
  pet.swim();
} else {
  pet.fly();
}Try

请注意,TypeScript 不仅知道在 if 分支中 petFish;它还知道在 else 分支中,你没有 Fish,所以你一定有一个 Bird

你可以使用类型守卫 isFish 来过滤 Fish | Bird 类型的数组,从而获得一个 Fish 类型的数组:

const zoo: (Fish | Bird)[] = [getSmallPet(), getSmallPet(), getSmallPet()];
const underWater1: Fish[] = zoo.filter(isFish);
// or, equivalently
const underWater2: Fish[] = zoo.filter(isFish) as Fish[];
 
// The predicate may need repeating for more complex examples
const underWater3: Fish[] = zoo.filter((pet): pet is Fish => {
  if (pet.name === "sharkey") return false;
  return isFish(pet);
});Try

类(Classes)可以使用 this is Type 来缩小其自身的类型。

这种基于 this 的类型守卫(this-based type guards)允许你在类或接口的方法中,根据某个检查的结果来收窄实例本身的类型。这在处理复杂的对象层级或 Fluent API(链式调用)时非常有用。

TypeScript

class FileSystemObject {
  isFile(): this is File {
    return this instanceof File;
  }
  isDirectory(): this is Directory {
    return this instanceof Directory;
  }
}

const obj: FileSystemObject = new File();

if (obj.isFile()) {
  // 这里的 obj 类型被缩小为 File
  obj.content;
}

你可以在 TypeScript 官方文档关于 Classes 的部分了解更多详细信息。

断言函数

类型还可以通过断言函数来缩小Assertion functions.

可辨识联合类型

到目前为止,我们看的大多数例子都集中在使用 stringbooleannumber 等简单类型来缩小单个变量的范围。虽然这很常见,但在 JavaScript 中,大多数时候我们会处理稍微复杂一些的结构。

为了更有动力,让我们想象一下,我们正在尝试对圆形(circles)和正方形(squares)等形状进行编码。圆形跟踪其半径(radiuses),而正方形跟踪其边长(side lengths)。我们将使用一个名为 kind 的字段来区分我们正在处理的是哪种形状。这是定义 Shape 的第一次尝试:

interface Shape {
  kind: "circle" | "square";
  radius?: number;
  sideLength?: number;
}Try

请注意,我们使用的是字符串字面量类型的联合:"circle""square",以此来告诉我们应该将形状分别视为圆形还是正方形。通过使用 "circle" | "square" 而不是简单的 string,我们可以避免拼写错误的问题。

image-20260104225229357

我们可以编写一个 getArea 函数,根据处理的是圆形还是正方形来应用相应的逻辑。我们先尝试处理圆形的情况。

image-20260104225239616

嗯,TypeScript 依然不知道在这里该如何处理。我们已经到了一个我们比类型检查器更了解自身值的情况。我们可以尝试使用非空断言(在 shape.radius 后面加一个 !)来表示 radius 肯定存在。

function getArea(shape: Shape) {
  if (shape.kind === "circle") {
    return Math.PI * shape.radius! ** 2;
  }
}

但这种方式并不理想。我们不得不通过非空断言(!)来对类型检查器“大声疾呼”,以确信 shape.radius 已定义,但如果我们开始重构代码,这些断言很容易出错。此外,在非 strictNullChecks 模式下,我们无论如何都能意外地访问这些字段(因为在读取时,可选属性会被假定为始终存在)。我们显然可以做得更好。

这种 Shape 编码方式的问题在于,类型检查器无法根据 kind 属性知道 radiussideLength 是否存在。我们需要将我们所知道的信息传达给类型检查器。考虑到这一点,让我们再次尝试定义 Shape

interface Circle {
  kind: "circle";
  radius: number;
}
 
interface Square {
  kind: "square";
  sideLength: number;
}
 
type Shape = Circle | Square;Try

在这里,我们已经适当地将 Shape 拆分成了两个具有不同 kind 属性值的类型,并且 radiussideLength 在它们各自的类型中被声明为必选属性。

让我们看看当我们尝试访问 Shaperadius 时会发生什么。

image-20260104225251587

与我们对 Shape 的第一个定义一样,这仍然是一个错误。当 radius 是可选属性时,我们会得到一个错误(在启用了 strictNullChecks 的情况下),因为 TypeScript 无法判断该属性是否存在。现在 Shape 是一个联合类型,TypeScript 告诉我们 shape 可能是一个 Square,而 Square 上并没有定义 radius!这两种解释都是正确的,但无论 strictNullChecks 如何配置,只有 Shape 的联合类型编码方式才会导致错误。

但是,如果我们再次尝试检查 kind 属性会发生什么呢?

image-20260104225301481

这解决了错误!当联合类型中的每个类型都包含一个具有字面量类型的共同属性时,TypeScript 会将其视为可辨识联合类型 (discriminated union),并可以据此缩小联合类型的成员范围。

在这种情况下,kind 就是那个共同属性(它被认为是 Shape可辨识属性 (discriminant property))。检查 kind 属性是否为 "circle" 会排除 Shape 中所有 kind 属性类型不为 "circle" 的类型。这使得 shape 的类型被缩小为 Circle

同样的检查在 switch 语句中也适用。现在我们可以尝试编写完整的 getArea 函数,而不需要任何讨厌的 ! 非空断言了。

image-20260104225312336这里的关键在于 Shape 的编码方式。向 TypeScript 传递正确的信息——即 CircleSquare 实际上是两个具有特定 kind 字段的独立类型——至关重要。这样做让我们能够编写类型安全的 TypeScript 代码,且代码外观与我们平时编写的 JavaScript 别无二致。由此,类型系统能够执行“正确”的操作,并推断出 switch 语句中每个分支的类型。

顺便提一下,你可以尝试玩一下上面的例子,删掉一些 return 关键字。你会发现,当在 switch 语句中意外执行到后续的 case 子句(fallthrough)时,类型检查可以帮助避免错误。

可辨识联合类型的用途不仅限于讨论圆形和正方形。它们非常适合表示 JavaScript 中的任何消息传递方案,例如在网络上传输消息(客户端/服务器通信),或者在状态管理框架中对变化(mutations)进行编码。

The never type

在进行类型缩小(narrowing)时,你可以将联合类型的选项减少到所有可能性都被移除、空无一物的情况。在这些情况下,TypeScript 会使用 never 类型来表示一个不应该存在状态。

穷举检查

never 类型可以赋值给任何类型;但是,没有任何类型可以赋值给 never(除了 never 本身)。这意味着你可以利用类型收窄,并依靠 neverswitch 语句中进行穷举检查。

例如,在我们的 getArea 函数中添加一个 default 分支,并尝试将 shape 赋值给 never。当所有可能的情况都已被处理时,这样做不会报错。

type Shape = Circle | Square;
 
function getArea(shape: Shape) {
  switch (shape.kind) {
    case "circle":
      return Math.PI * shape.radius ** 2;
    case "square":
      return shape.sideLength ** 2;
    default:
      const _exhaustiveCheck: never = shape;
      return _exhaustiveCheck;
  }
}Try

Shape 联合类型添加一个新成员,将会导致 TypeScript 报错:

image-20260104225324231

函数详解

无论是在本地定义的函数、从其他模块导入的函数,还是类上的方法,函数都是任何应用程序的基础构建块。它们同样也是值,就像其他值一样,TypeScript 有多种方式来描述如何调用函数。让我们来学习如何编写描述函数的类型。

函数类型表达式

描述函数最简单的方法是使用函数类型表达式。这些类型在语法上类似于箭头函数:

function greeter(fn: (a: string) => void) {
  fn("Hello, World");
}
 
function printToConsole(s: string) {
  console.log(s);
}
 
greeter(printToConsole);Try

语法 (a: string) => void 的意思是“一个具有名为 a、类型为 string 的参数,且没有返回值的函数”。与函数声明一样,如果没有指定参数类型,它将隐式地为 any

注意,参数名称是必需的。函数类型 (string) => void 的含义是“一个名为 string、类型为 any 的参数的函数”!

当然,我们可以使用类型别名(type alias)来为函数类型命名:

type GreetFunction = (a: string) => void;
function greeter(fn: GreetFunction) {
  // ...
}Try

调用签名

在 JavaScript 中,函数除了可以被调用外,还可以拥有属性。然而,函数类型表达式语法不允许声明属性。如果我们想描述一个既可调用又具有属性的对象,可以在对象类型中编写调用签名

type DescribableFunction = {
  description: string;
  (someArg: number): boolean;
};
function doSomething(fn: DescribableFunction) {
  console.log(fn.description + " returned " + fn(6));
}
 
function myFunc(someArg: number) {
  return someArg > 3;
}
myFunc.description = "default description";
 
doSomething(myFunc);Try

注意,与函数类型表达式相比,语法略有不同——在参数列表和返回类型之间使用 : 而不是 =>

构造签名

JavaScript 函数也可以通过 new 操作符调用。TypeScript 将这些函数称为构造函数,因为它们通常会创建一个新对象。你可以通过在调用签名前添加 new 关键字来编写构造签名

type SomeConstructor = {
  new (s: string): SomeObject;
};
function fn(ctor: SomeConstructor) {
  return new ctor("hello");
}Try

某些对象(如 JavaScript 的 Date 对象)可以在调用时使用或不使用 new 关键字。你可以将调用签名和构造签名任意组合在同一个类型中:

image-20260104225342872

泛型函数

编写一个输入类型与输出类型相关联,或者两个输入的类型以某种方式相关联的函数是很常见的。让我们先考虑一个返回数组第一个元素的函数:

function firstElement(arr: any[]) {
  return arr[0];
}Try

这个函数完成了它的工作,但遗憾的是它的返回类型是 any。如果函数能返回数组元素的类型,那就更好了。

在 TypeScript 中,当我们想要描述两个值之间的对应关系时,会使用泛型 (generics)。我们通过在函数签名中声明一个类型参数 (type parameter) 来实现这一点:

function firstElement<Type>(arr: Type[]): Type | undefined {
  return arr[0];
}Try

通过向该函数添加类型参数 Type 并在两个地方使用它,我们在函数的输入(数组)和输出(返回值)之间建立了联系。现在当我们调用它时,就会得到一个更具体的类型:

// s is of type 'string'
const s = firstElement(["a", "b", "c"]);
// n is of type 'number'
const n = firstElement([1, 2, 3]);
// u is of type undefined
const u = firstElement([]);Try

类型推断

请注意,在这个示例中我们不需要指定 Type。类型是由 TypeScript 自动推断——即自动选择的。

我们也可以使用多个类型参数。例如,一个独立版本的 map 函数看起来像这样:

function map<Input, Output>(arr: Input[], func: (arg: Input) => Output): Output[] {
  return arr.map(func);
}
 
// 参数 'n' 的类型是 'string'
// 'parsed' 的类型是 'number[]'
const parsed = map(["1", "2", "3"], (n) => parseInt(n));Try

请注意,在这个示例中,TypeScript 可以根据给定的字符串数组推断出 Input 类型参数,同时根据函数表达式的返回值(number)推断出 Output 类型参数。

泛型约束

我们已经编写了一些可以处理任何类型值的泛型函数。有时我们想要关联两个值,但只能对这些值的某个特定子集进行操作。在这种情况下,我们可以使用**约束(constraint)**来限制类型参数可以接受的类型种类。

让我们编写一个返回两个值中较长者的函数。为此,我们需要一个数字类型的 length 属性。我们通过编写 extends 子句将类型参数约束为该类型:

image-20260104225356422

在这个示例中有几点值得注意。我们允许 TypeScript 推断 longest 的返回类型。返回类型推断也适用于泛型函数。

因为我们将 Type 约束为 { length: number },所以我们被允许访问参数 ab.length 属性。如果没有这个类型约束,我们将无法访问这些属性,因为传入的值可能是其他没有 length 属性的类型。

longerArraylongerString 的类型是根据参数推断出来的。记住,泛型的核心在于将两个或多个具有相同类型的值关联起来!

最后,正如我们所希望的,对 longest(10, 100) 的调用会被拒绝,因为 number 类型没有 .length 属性。

Working with Constrained Values

Here’s a common error when working with generic constraints:

image-20260104225409450

It might look like this function is OK - Type is constrained to { length: number }, and the function either returns Type or a value matching that constraint. The problem is that the function promises to return the same kind of object as was passed in, not just some object matching the constraint. If this code were legal, you could write code that definitely wouldn’t work:

// 'arr' 获取的值为 { length: 6 }
const arr = minimumLength([1, 2, 3], 6);
// 这里会崩溃,因为数组有 'slice' 方法,但返回的对象没有!
console.log(arr.slice(0));Try

指定类型参数

TypeScript 通常可以推断出泛型调用中预期的类型参数,但并非总是如此。例如,假设你编写了一个合并两个数组的函数:

function combine<Type>(arr1: Type[], arr2: Type[]): Type[] {
  return arr1.concat(arr2);
}Try

通常情况下,使用类型不匹配的数组调用此函数会报错:

image-20260104225420678

但是,如果你确实打算这样做,可以手动指定 Type

const arr = combine<string | number>([1, 2, 3], ["hello"]);Try

编写优秀泛型函数的指南

编写泛型函数很有趣,但也很容易过度使用类型参数。拥有过多的类型参数,或者在不需要的地方使用约束,会降低类型推断的成功率,从而让函数调用者感到沮丧。

降低类型参数的层级

这里有两种编写函数的方式,它们看起来很相似:

function firstElement1<Type>(arr: Type[]) {
  return arr[0];
}
 
function firstElement2<Type extends any[]>(arr: Type) {
  return arr[0];
}
 
// a: number (良好)
const a = firstElement1([1, 2, 3]);
// b: any (糟糕)
const b = firstElement2([1, 2, 3]);Try

乍一看它们似乎是一样的,但 firstElement1 是编写此函数的更好方式。它的推断返回类型是 Type,而 firstElement2 的推断返回类型是 any,因为 TypeScript 必须使用约束类型来解析 arr[0] 表达式,而不是“等待”在调用期间解析元素。

规则:尽可能直接使用类型参数,而不是对其进行约束

使用更少的类型参数

这是另一对相似的函数:

function filter1<Type>(arr: Type[], func: (arg: Type) => boolean): Type[] {
  return arr.filter(func);
}
 
function filter2<Type, Func extends (arg: Type) => boolean>(
  arr: Type[],
  func: Func
): Type[] {
  return arr.filter(func);
}Try

我们创建了一个类型参数 Func,但它并没有关联两个值。这通常是一个危险信号,因为这意味着想要指定类型参数的调用者必须无缘无故地手动指定一个额外的类型参数。Func 除了让函数更难阅读和理解之外,没有任何作用!

规则:始终使用尽可能少的类型参数

类型参数应出现两次

有时我们会忘记函数可能并不需要是泛型的:

function greet<Str extends string>(s: Str) {
  console.log("Hello, " + s);
}
 
greet("world");Try

我们完全可以写一个更简单的版本:

function greet(s: string) {
  console.log("Hello, " + s);
}Try

记住,类型参数是为了关联多个值的类型。如果一个类型参数在函数签名中只使用了一次,那么它就没有关联任何东西。这包括推断出的返回类型;例如,如果 Strgreet 推断返回类型的一部分,它就会关联参数 and 返回类型,因此尽管在编写的代码中只出现了一次,但它实际上被使用了两次

规则:如果一个类型参数只出现在一个位置,请强烈考虑是否真的需要它

可选参数

JavaScript 中的函数经常接受数量可变的参数。例如,numbertoFixed 方法接受一个可选的小数位数:

function f(n: number) {
  console.log(n.toFixed()); // 0 个参数
  console.log(n.toFixed(3)); // 1 个参数
}Try

在 TypeScript 中,我们可以通过在参数后标记 ? 来将其模拟为可选参数:

function f(x?: number) {
  // ...
}
f(); // OK
f(10); // OKTry

尽管参数被指定为 number 类型,但参数 x 实际上具有 number | undefined 类型,因为 JavaScript 中未指定的参数会获得值 undefined

你也可以提供参数默认值:

function f(x = 10) {
  // ...
}Try

现在在 f 的函数体中,x 将具有 number 类型,因为任何 undefined 参数都会被替换为 10。请注意,当参数可选时,调用者始终可以传递 undefined,因为这仅仅是模拟“缺失”的参数:

// 全部 OK
f();
f(10);
f(undefined);Try

回调中的可选参数

一旦你了解了可选参数和函数类型表达式,在编写调用回调的函数时就很容易犯以下错误:

function myForEach(arr: any[], callback: (arg: any, index?: number) => void) {
  for (let i = 0; i < arr.length; i++) {
    callback(arr[i], i);
  }
}Try

当人们将 index? 写为可选参数时,通常的意图是希望这两个调用都是合法的:

myForEach([1, 2, 3], (a) => console.log(a));
myForEach([1, 2, 3], (a, i) => console.log(a, i));Try

这实际上意味着 callback 可能只带一个参数被调用。换句话说,函数定义表明实现可能是这样的:

function myForEach(arr: any[], callback: (arg: any, index?: number) => void) {
  for (let i = 0; i < arr.length; i++) {
    // 我今天不想提供索引
    callback(arr[i]);
  }
}Try

反过来,TypeScript 将强制执行此含义,并发出一些在实际中不太可能发生的错误:

image-20260104225441301

在 JavaScript 中,如果你调用一个函数时提供的参数多于其定义的参数,额外的参数会被直接忽略。TypeScript 的行为也是如此。具有较少参数(且类型相同)的函数总是可以替代具有较多参数的函数。

规则:在为回调编写函数类型时,除非你打算在调用该函数时不传递该参数,否则永远不要编写可选参数。

函数重载

一些 JavaScript 函数可以以多种参数数量 and 类型进行调用。例如,你可能会编写一个生成 Date 的函数,它既可以接受一个时间戳(一个参数),也可以接受月/日/年规范(三个参数)。

在 TypeScript 中,我们可以通过编写**重载签名(overload signatures)**来指定一个可以以不同方式调用的函数。为此,请编写一定数量的函数签名(通常是两个或更多),然后是函数体:

image-20260104225452013

在这个示例中,我们编写了两个重载:一个接受一个参数,另一个接受三个参数。前两个签名被称为重载签名。

然后,我们编写了一个具有兼容签名的函数实现。函数有一个实现签名(implementation signature),但此签名不能直接调用。尽管我们在必填参数之后编写了一个带有两个可选参数的函数,但它不能带两个参数进行调用!

重载签名与实现签名

这是一个常见的困惑来源。人们经常会写出如下代码,但不明白为什么会报错:

image-20260104225500686

再次强调,用于编写函数体的签名从外部是“不可见”的。

实现的签名从外部不可见。在编写重载函数时,你应当始终在函数实现之上定义两个或更多签名。

实现签名还必须与重载签名兼容。例如,以下函数会报错,因为实现签名没有以正确的方式匹配重载签名:

image-20260104225509984 image-20260104225516915编写优秀的重载

与泛型一样,在使用函数重载时,你也应该遵循一些准则。遵循这些原则将使你的函数更易于调用、理解和实现。

让我们考虑一个返回字符串或数组长度的函数:

function len(s: string): number;
function len(arr: any[]): number;
function len(x: any) {
  return x.length;
}Try

这个函数没问题;我们可以使用字符串或数组来调用它。但是,我们不能使用一个可能是字符串数组的值来调用它,因为 TypeScript 只能将函数调用解析为单个重载:

image-20260104225533554

由于两个重载具有相同的参数数量 and 相同的返回类型,我们可以转而编写一个非重载版本的函数:

function len(x: any[] | string) {
  return x.length;
}Try

这好多了!调用者可以使用任一类型的值来调用,而且作为一个额外的好处,我们不必去计算一个正确的实现签名。

尽可能优先使用联合类型参数而不是重载

在函数中声明 this

TypeScript 将通过代码流分析推断函数中的 this 是什么,例如:

const user = {
  id: 123,
 
  admin: false,
  becomeAdmin: function () {
    this.admin = true;
  },
};Try

TypeScript 理解函数 user.becomeAdmin 对应的 this 是外部对象 user。嘿,这在很多情况下已经足够了,但在很多其他情况下,你需要更多地控制 this 代表的对象。JavaScript 规范规定不能有名为 this 的参数,因此 TypeScript 利用这一语法空间让你在函数体中声明 this 的类型。

interface DB {
  filterUsers(filter: (this: User) => boolean): User[];
}
 
const db = getDB();
const admins = db.filterUsers(function (this: User) {
  return this.admin;
});Try

这种模式在回调风格的 API 中很常见,通常由另一个对象控制函数的调用时机。请注意,你需要使用 function 关键字而不是箭头函数来获得此行为:

image-20260104225550742

其他需要了解的类型

在处理函数类型时,你还会经常遇到一些额外的类型。与所有类型一样,你可以在任何地方使用它们,但这些类型在函数的语境下尤为相关。

void

void 表示不返回值的函数的返回值。每当函数没有任何 return 语句,或者这些 return 语句没有返回任何显式值时,它就是推断出的类型:

// 推断出的返回类型是 void
function noop() {
  return;
}Try

在 JavaScript 中,不返回任何值的函数会隐式返回 undefined 值。然而,在 TypeScript 中 void and undefined 并不是一回事。本章末尾有更多详细信息。

void 不等于 undefined

object

特殊类型 object 指的是任何非原始类型的值(即不是 string, number, bigint, boolean, symbol, nullundefined)。这与空对象类型 { } 不同,也与全局类型 Object 不同。你很可能永远不会用到 Object

object 不等于 Object始终使用 object

请注意,在 JavaScript 中,函数值也是对象:它们拥有属性,原型链中有 Object.prototype,是 instanceof Object,你可以对它们调用 Object.keys,等等。因此,在 TypeScript 中函数类型被视为 object

unknown

unknown 类型代表任何值。这与 any 类型相似,但更安全,因为对 unknown 类型的值执行任何操作都是不合法的:

image-20260104225604168

这在描述函数类型时很有用,因为你可以描述接受任何值的函数,而无需在函数体中处理 any 类型的值。

反之,你也可以描述一个返回未知类型值的函数:

function safeParse(s: string): unknown {
  return JSON.parse(s);
}
 
// 需要小心处理 'obj'!
const obj = safeParse(someRandomString);Try

never

有些函数永远不会返回值:

function fail(msg: string): never {
  throw new Error(msg);
}Try

never 类型表示从未观察到的值。在返回类型中,这意味着函数会抛出异常或终止程序的执行。

当 TypeScript 确定联合类型中没有任何剩余选项时,也会出现 never

function fn(x: string | number) {
  if (typeof x === "string") {
    // 做一些事
  } else if (typeof x === "number") {
    // 做另一些事
  } else {
    x; // 此时类型为 'never'!
  }
}Try

Function

全局类型 Function 描述了 JavaScript 中所有函数值都具有的属性,如 bind, call, apply 等。它还有一个特殊属性,即 Function 类型的值总是可以被调用;这些调用返回 any

function doSomething(f: Function) {
  return f(1, 2, 3);
}Try

这是一个未定义类型的函数调用,通常由于其不安全的 any 返回类型而最好避免。

如果你需要接受一个任意函数但不打算调用它,() => void 类型通常更安全。

剩余参数与实参

背景阅读: 剩余参数 (Rest Parameters) 扩展语法 (Spread Syntax)

剩余参数

除了使用可选参数或重载来使函数接受各种固定数量的参数外,我们还可以使用剩余参数(rest parameters)来定义接受无限数量参数的函数。

剩余参数出现在所有其他参数之后,并使用 ... 语法:

function multiply(n: number, ...m: number[]) {
  return m.map((x) => n * x);
}
// 'a' 获取的值为 [10, 20, 30, 40]
const a = multiply(10, 1, 2, 3, 4);Try

在 TypeScript 中,这些参数的类型注解隐式地为 any[] 而非 any,并且提供的任何类型注解都必须采用 Array<T>T[] 的形式,或者是元组类型(我们稍后会学到)。

剩余实参

反之,我们可以使用扩展语法从一个可迭代对象(例如数组)中提供可变数量 of 参数。例如,数组的 push 方法可以接受任意数量 of 参数:

const arr1 = [1, 2, 3];
const arr2 = [4, 5, 6];
arr1.push(...arr2);Try

请注意,通常情况下,TypeScript 不会假设数组是不可变的。这可能会导致一些令人惊讶的行为:

image-20260104225619602

针对这种情况,最佳修复方案取决于你的代码,但通常使用 const 上下文是最直接的解决方法:

// 推断为长度为 2 的元组
const args = [8, 5] as const;
// OK
const angle = Math.atan2(...args);Try

在针对旧版运行时使用剩余参数时,可能需要开启 downlevelIteration

参数解构

背景阅读: 解构赋值 (Destructuring Assignment)

你可以使用参数解构,方便地将作为参数提供的对象解压为函数体中的一个或多个局部变量。在 JavaScript 中,它看起来像这样:

function sum({ a, b, c }) {
  console.log(a + b + c);
}
sum({ a: 10, b: 3, c: 9 });

对象的类型注解位于解构语法之后:

function sum({ a, b, c }: { a: number; b: number; c: number }) {
  console.log(a + b + c);
}Try

这看起来可能有点冗长,但你也可以在这里使用命名类型:

// 与前一个示例相同
type ABC = { a: number; b: number; c: number };
function sum({ a, b, c }: ABC) {
  console.log(a + b + c);
}Try

函数的可赋值性

返回类型 void

函数的 void 返回类型可能会产生一些不同寻常但符合预期的行为。

具有 void 返回类型的上下文类型(Contextual typing)并不强制函数返回任何内容。换句话说,当一个具有 void 返回类型的上下文函数类型(type voidFunc = () => void)被实现时,它可以返回任何其他值,但该值将被忽略。

因此,以下 () => void 类型的实现是有效的:

type voidFunc = () => void;
 
const f1: voidFunc = () => {
  return true;
};
 
const f2: voidFunc = () => true;
 
const f3: voidFunc = function () {
  return true;
};Try

当这些函数之一的返回值被赋值给另一个变量时,它将保留 void 类型:

const v1 = f1();
 
const v2 = f2();
 
const v3 = f3();Try

这种行为的存在是为了确保以下代码有效,即使 Array.prototype.push 返回一个数字,而 Array.prototype.forEach 方法期望一个返回类型为 void 的函数。

const src = [1, 2, 3];
const dst = [0];
 
src.forEach((el) => dst.push(el));Try

还有一个特殊情况需要注意:当一个字面量函数定义具有 void 返回类型时,该函数绝对不能返回任何内容。

function f2(): void {
  // @ts-expect-error
  return true;
}
 
const f3 = function (): void {
  // @ts-expect-error
  return true;
};Try

有关 void 的更多信息,请参考以下文档条目:

对象类型

在 JavaScript 中,我们对数据进行分组和传递的基本方式是通过对象。在 TypeScript 中,我们通过**对象类型(object types)**来表示它们。

正如我们所见,它们可以是匿名的:

function greet(person: { name: string; age: number }) {
  return "Hello " + person.name;
}Try

或者通过使用接口来命名:

interface Person {
  name: string;
  age: number;
}
 
function greet(person: Person) {
  return "Hello " + person.name;
}Try

或者通过类型别名:

type Person = {
  name: string;
  age: number;
};
 
function greet(person: Person) {
  return "Hello " + person.name;
}Try

在上述三个示例中,我们编写了接受对象的函数,这些对象包含属性 name(必须是 string)和 age(必须是 number)。

快速参考

我们为 typeinterface 都准备了速查表(cheat-sheets),如果你想快速浏览日常的重要语法,可以查看它们。

属性修饰符

对象类型中的每个属性可以指定几件事:类型、属性是否可选以及属性是否可写。

可选属性

很多时候,我们会处理可能设置了某个属性的对象。在这种情况下,我们可以通过在属性名称末尾添加问号(?)来将这些属性标记为可选

interface PaintOptions {
  shape: Shape;
  xPos?: number;
  yPos?: number;
}
 
function paintShape(opts: PaintOptions) {
  // ...
}
 
const shape = getShape();
paintShape({ shape });
paintShape({ shape, xPos: 100 });
paintShape({ shape, yPos: 100 });
paintShape({ shape, xPos: 100, yPos: 100 });Try

在这个示例中,xPosyPos 都被视为可选的。我们可以选择提供其中任何一个,因此上面对 paintShape 的每一次调用都是有效的。可选性真正的含义是:如果设置了该属性,它最好具有指定的类型。

我们也可以读取这些属性——但当我们在 strictNullChecks 模式下这样做时,TypeScript 会告诉我们它们可能是 undefined

image-20260104225655295

在 JavaScript 中,即使属性从未被设置过,我们仍然可以访问它——它只会返回 undefined。我们可以通过检查来专门处理 undefined

image-20260104225704743

请注意,这种为未指定值设置默认值的模式非常常见,以至于 JavaScript 拥有专门支持它的语法。

image-20260104225716999

在这里,我们对 paintShape 的参数使用了解构模式,并为 xPosyPos 提供了默认值。现在,xPosyPospaintShape 的函数体中都肯定存在,但对于任何 paintShape 的调用者来说都是可选的。

请注意,目前无法在解构模式中放置类型注解。这是因为以下语法在 JavaScript 中已经代表了不同的含义。

image-20260104225738851

在对象解构模式中,shape: Shape 的意思是“获取属性 shape 并将其在本地重定义为名为 Shape 的变量”。同样地,xPos: number 会创建一个名为 number 的变量,其值基于参数的 xPos

readonly 属性

在 TypeScript 中,属性也可以标记为 readonly(只读)。虽然这不会改变运行时的任何行为,但在类型检查期间,标记为 readonly 的属性是不可写的。

image-20260104225758879

使用 readonly 修饰符并不一定意味着值是完全不可变的——换句话说,不代表其内部内容不能更改。它仅仅意味着属性本身不能被重新改写。

image-20260104225810783

管理对 readonly 含义的预期非常重要。它在开发过程中向 TypeScript 发出信号,表明该对象应如何被使用,这非常有用。在检查两个类型是否兼容时,TypeScript 不会考虑这些类型上的属性是否为 readonly,因此只读属性也可能通过别名发生改变。

interface Person {
  name: string;
  age: number;
}
 
interface ReadonlyPerson {
  readonly name: string;
  readonly age: number;
}
 
let writablePerson: Person = {
  name: "Person McPersonface",
  age: 42,
};
 
// 有效
let readonlyPerson: ReadonlyPerson = writablePerson;
 
console.log(readonlyPerson.age); // 打印 '42'
writablePerson.age++;
console.log(readonlyPerson.age); // 打印 '43'Try

使用映射修饰符,你可以移除 readonly 属性。

索引签名

有时你无法提前知道一个类型的所有属性名称,但你确实知道值的结构。

在这些情况下,你可以使用索引签名来描述可能值的类型,例如:

image-20260104225823659

在上面,我们有一个 StringArray 接口,它具有一个索引签名。这个索引签名声明:当使用 numberStringArray 进行索引时,它将返回一个 string

只有部分类型允许作为索引签名的属性:string, number, symbol, 模板字符串模式,以及仅由这些类型组成的联合类型。

支持多种类型的索引器是可行的。请注意,当同时使用 numberstring 索引器时,从数字索引器返回的类型必须是字符串索引器返回类型的子类型。这是因为在进行数字索引时,JavaScript 实际上会在索引对象之前将其转换为字符串。这意味着使用 100(数字)进行索引与使用 "100"(字符串)进行索引是一回事,因此两者必须保持一致。

image-20260104225943953

虽然字符串索引签名是描述“字典”模式的强大方式,但它们也强制要求所有属性都匹配其返回类型。这是因为字符串索引声明了 obj.property 也可以通过 obj["property"] 获得。在以下示例中,name 的类型与字符串索引的类型不匹配,类型检查器会报错:

image-20260104225954526

However, properties of different types are acceptable if the index signature is a union of the property types:

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

Finally, you can make index signatures readonly in order to prevent assignment to their indices:

image-20260104230005705

You can’t set myArray[2] because the index signature is readonly.

额外属性检查

在类型系统中,对象在何处以及如何被分配类型会有所不同。其中一个关键例子就是额外属性检查,它在对象创建并分配给对象类型时,会更彻底地验证该对象。

image-20260104230017401

注意传给 createSquare 的参数拼写为 colour 而非 color。在纯 JavaScript 中,这种事情会静默失败。

你可能会辩称这个程序的类型是正确的,因为 width 属性是兼容的,且 color 属性不存在,而额外的 colour 属性无关紧要。

然而,TypeScript 认为这段代码中可能存在 bug。对象字面量在分配给其他变量或作为参数传递时,会受到特殊对待并接受额外属性检查。如果对象字面量具有“目标类型”不具备的任何属性,你将会收到一个错误:

image-20260104230028767

绕过这些检查其实非常简单。最简单的方法就是直接使用类型断言:

let mySquare = createSquare({ width: 100, opacity: 0.5 } as SquareConfig);Try

但是,如果你确定对象可以具有某些以特殊方式使用的额外属性,更好的方法是添加字符串索引签名。如果 SquareConfig 可以具有上述类型的 colorwidth 属性,但也可以具有任意数量的其他属性,那么我们可以这样定义它:

interface SquareConfig {
  color?: string;
  width?: number;
  [propName: string]: unknown;
}Try

在这里我们的意思是,SquareConfig 可以有任意数量的属性,只要它们不是 colorwidth,它们的类型就无关紧要。

最后一种绕过这些检查的方法(可能有点令人惊讶)是将对象赋值给另一个变量:由于分配 squareOptions 时不会进行额外属性检查,因此编译器不会报错:

let squareOptions = { colour: "red", width: 100 };
let mySquare = createSquare(squareOptions);Try

只要 squareOptionsSquareConfig 之间存在共同属性,上述解决方法就有效。在这个例子中,共同属性是 width。但是,如果变量没有任何共同的对象属性,它将会失败。例如:

image-20260104230041302

请记住,对于像上面这样简单的代码,你可能不应该尝试“绕过”这些检查。对于具有方法和持有状态的更复杂的对象字面量,你可能需要记住这些技术,但大多数额外属性错误实际上都是 bug。

这意味着如果你在处理类似选项包(option bags)时遇到了额外属性检查问题,你可能需要修改一些类型声明。在这个例子中,如果将同时具有 colorcolour 属性的对象传递给 createSquare 是可以接受的,那么你应该修正 SquareConfig 的定义以反映这一点。

扩展类型

拥有某些类型的更具体版本是非常常见的。例如,我们可能有一个 BasicAddress 类型,用于描述寄送包裹所需的字段。

interface BasicAddress {
  name?: string;
  street: string;
  city: string;
  country: string;
  postalCode: string;
}Try

在某些情况下这已经足够了,但如果一个地址所在的建筑有多个单元,地址通常会关联一个单元号。我们可以据此描述一个 AddressWithUnit

interface AddressWithUnit {
  name?: string;
  unit: string;
  street: string;
  city: string;
  country: string;
  postalCode: string;
}Try

这可以完成任务,但缺点是当我们的更改纯粹是增量式的时候,我们不得不重复 BasicAddress 中的所有其他字段。相反,我们可以扩展原始的 BasicAddress 类型,只添加 AddressWithUnit 特有的新字段。

interface BasicAddress {
  name?: string;
  street: string;
  city: string;
  country: string;
  postalCode: string;
}
 
interface AddressWithUnit extends BasicAddress {
  unit: string;
}Try

接口上的 extends 关键字允许我们有效地从其他命名类型中复制成员,并添加我们想要的任何新成员。这对于减少我们需要编写的类型声明样板代码非常有用,并且可以传达出多个相同属性的不同声明可能是相关的意图。例如,AddressWithUnit 不需要重复 street 属性,且由于 street 源自 BasicAddress,读者会知道这两个类型在某种程度上是相关的。

接口也可以从多个类型扩展。

interface Colorful {
  color: string;
}
 
interface Circle {
  radius: number;
}
 
interface ColorfulCircle extends Colorful, Circle {}
 
const cc: ColorfulCircle = {
  color: "red",
  radius: 42,
};Try

交叉类型

接口允许我们通过扩展现有类型来构建新类型。TypeScript 提供了另一种名为**交叉类型(intersection types)**的构造,主要用于组合现有的对象类型。

交叉类型是使用 & 运算符定义的。

interface Colorful {
  color: string;
}
interface Circle {
  radius: number;
}
 
type ColorfulCircle = Colorful & Circle;Try

在这里,我们将 ColorfulCircle 交叉,生成了一个拥有 Colorful Circle 所有成员的新类型。

image-20260104230059447

接口扩展 vs. 交叉类型

我们刚刚研究了两种组合类型的相似方式,但它们实际上有着细微的区别。通过接口,我们可以使用 extends 子句从其他类型扩展;通过交叉类型,我们可以实现类似的效果,并使用类型别名为结果命名。两者之间的主要区别在于如何处理冲突,而这种区别通常也是你在接口和交叉类型的类型别名之间做出选择的主要原因之一。

如果以相同的名称定义接口,TypeScript 将在属性兼容的情况下尝试合并它们。如果属性不兼容(即具有相同的属性名但类型不同),TypeScript 将会报错。

在交叉类型的情况下,具有不同类型的属性将自动合并。稍后使用该类型时,TypeScript 将期望该属性同时满足两种类型,这可能会产生意想不到的结果。

例如,以下代码会抛出错误,因为属性是不兼容的:

interface Person {
  name: string;
}

interface Person {
  name: number;
}

相比之下,以下代码可以编译,但结果是一个 never 类型:

image-20260104230113091

在这种情况下,Staff 要求 name 属性既是 string 又是 number,这导致属性的类型变为 never

泛型对象类型

让我们想象一个 Box 类型,它可以包含任何值——string, number, Giraffe,或者是任何东西。

interface Box {
  contents: any;
}Try

目前,contents 属性的类型是 any。这虽然可行,但可能会在后续导致意外。

我们可以改用 unknown,但这意味着在已经知道 contents 类型的情况下,我们需要进行预防性检查,或者使用容易出错的类型断言。

interface Box {
  contents: unknown;
}
 
let x: Box = {
  contents: "hello world",
};
 
// 我们由于检查 'x.contents'
if (typeof x.contents === "string") {
  console.log(x.contents.toLowerCase());
}
 
// 或者我们可以使用类型断言
console.log((x.contents as string).toLowerCase());Try

一种类型安全的方法是为每种 contents 类型构建不同的 Box 类型。

interface NumberBox {
  contents: number;
}
 
interface StringBox {
  contents: string;
}
 
interface BooleanBox {
  contents: boolean;
}Try

但这意味着我们将不得不创建不同的函数,或者函数的重载,来对这些类型进行操作。

function setContents(box: StringBox, newContents: string): void;
function setContents(box: NumberBox, newContents: number): void;
function setContents(box: BooleanBox, newContents: boolean): void;
function setContents(box: { contents: any }, newContents: any) {
  box.contents = newContents;
}Try

这是大量的样板代码。此外,我们以后可能还需要引入新的类型和重载。这令人沮丧,因为我们的 Box 类型和重载实际上都是一样的。

相反,我们可以创建一个声明了类型参数(type parameter)泛型 Box 类型:

interface Box<Type> {
  contents: Type;
}Try

你可以将其读作:“TypeBox 是一个其 contents 具有 Type 类型的东西”。稍后,当我们引用 Box 时,我们需要给出一个**类型实参(type argument)**来代替 Type

let box: Box<string>;Try

Box 想象成一个真实类型的模板,其中 Type 是一个占位符,会被替换为其他类型。当 TypeScript 看到 Box<string> 时,它会将 Box<Type> 中每一个出现的 Type 替换为 string,最终效果就像是 { contents: string }。换句话说,Box<string> 与我们之前的 StringBox 工作方式完全相同。

image-20260104230129528

Box 是可复用的,因为 Type 可以被任何东西替代。这意味着当我们为一个新类型需要一个 box 时,我们根本不需要声明一个新的 Box 类型(尽管如果愿意,我们当然可以这么做)。

interface Box<Type> {
  contents: Type;
}
 
interface Apple {
  // ....
}
 
// 等同于 '{ contents: Apple }'.
type AppleBox = Box<Apple>;Try

这也意味着我们可以通过使用泛型函数来完全避免重载。

function setContents<Type>(box: Box<Type>, newContents: Type) {
  box.contents = newContents;
}Try

值得注意的是,类型别名也可以是泛型的。我们原本定义的 Box<Type> 接口是:

interface Box<Type> {
  contents: Type;
}Try

也可以通过使用类型别名来定义:

type Box<Type> = {
  contents: Type;
};Try

由于类型别名与接口不同,它可以描述除对象类型以外的更多内容,因此我们还可以使用它们来编写其他类型的泛型辅助类型。

image-20260104230143699

我们稍后会再次回到类型别名的话题。

Array 类型

泛型对象类型通常是某种容器类型,它们独立于所包含元素的类型而工作。数据结构以这种方式工作是理想的,这样它们就可以在不同的数据类型之间复用。

事实证明,在整本手册中我们一直在使用这样一种类型:Array 类型。每当我们写出 number[]string[] 这样的类型时,那其实只是 Array<number>Array<string> 的简写。

function doSomething(value: Array<string>) {
  // ...
}
 
let myArray: string[] = ["hello", "world"];
 
// 两种方式都可以!
doSomething(myArray);
doSomething(new Array("hello", "world"));Try

就像上面的 Box 类型一样,Array 本身也是一个泛型类型。

interface Array<Type> {
  /**
   * 获取或设置数组的长度。
   */
  length: number;
 
  /**
   * 移除数组的最后一个元素并返回它。
   */
  pop(): Type | undefined;
 
  /**
   * 向数组追加新元素,并返回数组的新长度。
   */
  push(...items: Type[]): number;
 
  // ...
}Try

现代 JavaScript 还提供了其他泛型数据结构,如 Map<K, V>Set<T>Promise<T>。这其实仅仅意味着,由于 MapSetPromise 的行为方式,它们可以处理任何类型集合。

ReadonlyArray 类型

ReadonlyArray 是一个特殊的类型,用于描述不应该被改变的数组。

image-20260104230248365

就像属性的 readonly 修饰符一样,它主要是一个我们可以用来表达意图的工具。当我们看到一个返回 ReadonlyArray 的函数时,它告诉我们不应该更改其中的内容;而当我们看到一个接收 ReadonlyArray 的函数时,它告诉我们可以向该函数传递任何数组,而无需担心其内容会被更改。

Array 不同,没有我们可以使用的 ReadonlyArray 构造函数。

image-20260104230259637

相反,我们可以将普通的 Array 赋值给 ReadonlyArray

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

正如 TypeScript 为 Array<Type> 提供了 Type[] 这一简写语法一样,它也为 ReadonlyArray<Type> 提供了 readonly Type[] 这一简写语法。

image-20260104230309880

最后需要注意的一点是,与 readonly 属性修饰符不同,普通 ArrayReadonlyArray 之间的可赋值性不是双向的。

image-20260104230319023

元组类型

**元组类型(tuple type)**是另一种 Array 类型,它确切地知道包含多少个元素,以及在特定位置包含哪些类型。

type StringNumberPair = [string, number];Try

在这里,StringNumberPairstringnumber 的元组类型。像 ReadonlyArray 一样,它在运行时没有表现形式,但对 TypeScript 意义重大。对于类型系统,StringNumberPair 描述了索引 0 包含 string 且索引 1 包含 number 的数组。

image-20260104230337018

如果我们尝试索引超出元素数量的范围,将会得到一个错误。

image-20260104230348082

我们还可以使用 JavaScript 的数组解构来解构元组。

image-20260104230357960

元组类型在高度基于约定的 API 中非常有用,因为在这些 API 中每个元素的含义都是“显而易见”的。这使得我们在解构变量时可以灵活地为它们命名。在上面的示例中,我们能够将元素 01 命名为任何我们想要的名称。

然而,由于并非每个用户都对什么是显而易见持有相同的观点,因此重新考虑在 API 中使用具有描述性属性名称的对象是否更好,可能是值得的。

除了这些长度检查之外,像这样简单的元组类型等同于声明了特定索引属性,并使用数字字面量类型声明了 lengthArray 版本。

interface StringNumberPair {
  // 专门的属性
  length: 2;
  0: string;
  1: number;
 
  // 其他 'Array<string | number>' 成员...
  slice(start?: number, end?: number): Array<string | number>;
}Try

你可能感兴趣的另一件事是,通过在元素类型后写一个问号(?),元组可以拥有可选属性。可选的元组元素只能放在末尾,并且也会影响 length 的类型。

image-20260104230430499

元组也可以拥有剩余元素(rest elements),剩余元素必须是数组/元组类型。

type StringNumberBooleans = [string, number, ...boolean[]];
type StringBooleansNumber = [string, ...boolean[], number];
type BooleansStringNumber = [...boolean[], string, number];Try
  • StringNumberBooleans 描述了一个前两个元素分别是 stringnumber,但后面可以跟着任意数量的 boolean 的元组。
  • StringBooleansNumber 描述了一个首个元素是 string,然后是任意数量的 boolean,并以一个 number 结尾的元组。
  • BooleansStringNumber 描述了一个起始元素是任意数量的 boolean,并以一个 string 然后是一个 number 结尾的元组。

具有剩余元素的元组没有固定的“长度”——它只有一组位于不同位置的已知元素。

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

为什么可选元素和剩余元素会有用呢?它允许 TypeScript 将元组与参数列表对应起来。元组类型可以用于剩余参数与实参,因此:

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

基本上等同于:

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

当你想要使用剩余参数接受可变数量的参数,并且需要最少数量的元素,但又不想要引入中间变量时,这非常方便。

readonly 元组类型

关于元组类型的最后一点说明——元组类型有 readonly 变体,可以通过在它们前面加上 readonly 修饰符来指定——就像数组的简写语法一样。

function doSomething(pair: readonly [string, number]) {
  // ...
}Try

正如你所预料的,在 TypeScript 中不允许对 readonly 元组的任何属性进行写入。

image-20260104230444967

在大多数代码中,元组往往被创建后就保持不变,因此尽可能将类型标注为 readonly 元组是一个很好的默认做法。考虑到带有 const 断言的数组字面量会被推断为 readonly 元组类型,这一点也很重要。

image-20260104230456866

在这里,distanceFromOrigin 从不修改其元素,但它期望一个可变的元组。由于 point 的类型被推断为 readonly [3, 4],它将与 [number, number] 不兼容,因为该类型无法保证 point 的元素不会被更改。

从类型创建类型

TypeScript 的类型系统非常强大,因为它允许用其他类型来表达类型。

这种思想最简单的形式就是泛型。此外,我们还可以使用各种各样的类型运算符(type operators)。也可以用已有的值来表示类型

通过组合各种类型运算符,我们可以用简洁且易于维护的方式表达复杂的操作和值。本节将介绍如何根据现有类型或值来表达新类型。

泛型 (Generics)

软件工程的一个主要部分是构建不仅具有定义明确且一致的 API,而且还具有可复用性的组件。能够处理今天的数据以及明天的数据的组件,将为你构建大型软件系统提供最灵活的能力。

在 C# 和 Java 等语言中,用于创建可复用组件的工具箱中的主要工具之一是泛型(generics),即能够创建一个可以处理多种类型而非单一类型的组件。这允许用户消费这些组件并使用他们自己的类型。

泛型的 Hello World

首先,让我们来做一个泛型的“hello world”:identity 函数。identity 函数是一个会返回任何传进来的参数的函数。你可以用类似于 echo 命令的方式来思考它。

如果没有泛型,我们必须给 identity 函数一个具体的类型:

function identity(arg: number): number {
  return arg;
}Try

或者,我们可以使用 any 类型来描述 identity 函数:

function identity(arg: any): any {
  return arg;
}Try

虽然使用 any 确实是泛型的,因为它会导致函数接受 arg 类型的任何及所有类型,但实际上我们在函数返回时丢失了有关该类型的信息。如果我们传入一个数字,我们拥有的唯一信息就是可以返回任何类型。

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

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

我们现在为 identity 函数添加了一个类型变量 Type。这个 Type 允许我们捕获用户提供的类型(例如 number),以便我们稍后可以使用该信息。在这里,我们再次使用 Type 作为返回类型。通过检查,我们现在可以看到参数和返回类型使用了相同的类型。这允许我们将该类型信息从函数的一端传递到另一端。

我们说这个版本的 identity 函数是泛型的,因为它适用于一系列类型。与使用 any 不同,它也与第一个使用数字作为参数和返回类型的 identity 函数一样精确(即它不会丢失任何信息)。

编写完泛型 identity 函数后,我们可以通过两种方式之一调用它。第一种方式是将所有参数(包括类型参数)传递给函数:

image-20260104230803345

在这里,我们将 Type 显式设置为 string,作为函数调用的参数之一,使用 <> 而非 () 包裹该参数。

第二种方式也许是最常见的。在这里,我们使用类型参数推断(type argument inference)——也就是说,我们希望编译器根据我们传入的参数类型自动为我们设置 Type 的值:

image-20260104231250324

请注意,我们不必在尖括号(<>)中显式传递类型;编译器只是查看了值 "myString",并将 Type 设置为它的类型。虽然类型参数推断是保持代码简短且更具可读性的有用工具,但当编译器无法推断类型时(这在更复杂的示例中可能会发生),你可能需要像上一个示例那样显式传入类型参数。

使用泛型类型变量

当你开始使用泛型时,你会注意到当你创建像 identity 这样的泛型函数时,编译器会强制要求你在函数体中正确使用任何泛型类型的参数。也就是说,你实际上要像处理任何及所有类型一样处理这些参数。

如果我们还想在每次调用时将参数 arg 的长度打印到控制台,该怎么办?我们可能会尝试这样写:

image-20260104231310362

当我们这样做时,编译器会给出一个错误,提示我们正在使用 arg.length 成员,但我们并没有在任何地方声明 arg 具有这个成员。记住,我们之前说过这些类型变量代表任何及所有类型,所以使用这个函数的人可能会传入一个 number,而 number 并没有 .length 成员。

假设我们实际上打算让这个函数作用于 Type 的数组而非直接作用于 Type。既然我们在处理数组,那么 .length 成员应该是可用的。我们可以像创建其他类型的数组一样来描述它:

function loggingIdentity<Type>(arg: Type[]): Type[] {
  console.log(arg.length);
  return arg;
}Try

你可以将 loggingIdentity 的类型读作:“泛型函数 loggingIdentity 接收一个类型参数 Type,以及一个类型为 Type 数组的参数 arg,并返回一个 Type 数组”。如果我们传入一个数字数组,我们将得到一个数字数组作为返回,因为 Type 会绑定到 number。这允许我们将泛型类型变量 Type 作为我们正在处理的类型的一部分,而不是整个类型,从而赋予我们更大的灵活用性。

我们也可以用这种方式编写示例:

function loggingIdentity<Type>(arg: Array<Type>): Array<Type> {
  console.log(arg.length); // Array 有 .length 属性,所以不再报错
  return arg;
}Try

你可能已经熟悉其他语言中的这种类型风格。在下一节中,我们将介绍如何创建你自己的泛型类型,如 Array<Type>

泛型类型

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

泛型函数的类型就像非泛型函数的类型一样,类型参数列在最前面,类似于函数声明:

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

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

function identity<Type>(arg: Type): Type {
  return arg;
}
 
let myIdentity: <Input>(arg: Input) => Input = identity;Try

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

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

这引导我们编写第一个泛型接口。让我们将上一个示例中的对象字面量移动到一个接口中:

interface GenericIdentityFn {
  <Type>(arg: Type): Type;
}
 
function identity<Type>(arg: Type): Type {
  return arg;
}
 
let myIdentity: GenericIdentityFn = identity;Try

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

interface GenericIdentityFn<Type> {
  (arg: Type): Type;
}
 
function identity<Type>(arg: Type): Type {
  return arg;
}
 
let myIdentity: GenericIdentityFn<number> = identity;Try

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

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

泛型类

泛型类的形状与泛型接口相似。泛型类在类名后面有一对尖括号(<>)包裹的泛型类型参数列表。

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;
};Try

这是对 GenericNumber 类的一个相当字面化的使用,但你可能已经注意到,没有任何东西限制它只能使用 number 类型。我们本可以使用 string 甚至更复杂的对象。

let stringNumeric = new GenericNumber<string>();
stringNumeric.zeroValue = "";
stringNumeric.add = function (x, y) {
  return x + y;
};
 
console.log(stringNumeric.add(stringNumeric.zeroValue, "test"));Try

就像接口一样,将类型参数放在类本身上可以让我们确保类的所有属性都使用相同的类型。

正如我们在类章节中所涵盖的,一个类的类型有两个方面:静态部分和实例部分。泛型类仅对其实例部分而非静态部分是泛型的,因此在处理类时,静态成员不能使用类的类型参数。

泛型约束

如果你还记得之前的示例,有时你可能想要编写一个泛型函数,它作用于一组类型,而你对这组类型将具有哪些能力有一定的了解。在我们的 loggingIdentity 示例中,我们希望能够访问 arg.length 属性,但编译器无法证明每种类型都具有 .length 属性,因此它警告我们不能做此假设。

image-20260104231336548

我们不想处理任何及所有类型,而是希望将此函数约束为仅处理同时也具有 .length 属性的任何及所有类型。只要类型具有此成员,我们就允许它,但要求它至少具有此成员。为此,我们必须将我们的要求列为对 Type 可能是什么的约束。

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

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

由于泛型函数现在受到了约束,它将不再适用于任何及所有类型:

image-20260104231347643

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

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

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

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

image-20260104231358233

在泛型中使用类类型

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

function create<Type>(c: { new (): Type }): Type {
  return new c();
}Try

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

class BeeKeeper {
  hasMask: boolean = true;
}
 
class ZooKeeper {
  nametag: string = "Mikle";
}
 
class Animal {
  numLegs: number = 4;
}
 
class Bee extends Animal {
  numLegs = 6;
  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;Try

此模式被用于支持 mixins 设计模式。

泛型参数默认值

通过为泛型类型参数声明默认值,你可以使指定相应的类型实参变为可选。例如,一个创建新 HTMLElement 的函数。在不传参数的情况下调用该函数会生成一个 HTMLDivElement;以一个元素作为第一个参数调用该函数会生成该参数类型的元素。你还可以选择性地传递子元素列表。以前你必须这样定义该函数:

declare function create(): Container<HTMLDivElement, HTMLDivElement[]>;
declare function create<T extends HTMLElement>(element: T): Container<T, T[]>;
declare function create<T extends HTMLElement, U extends HTMLElement>(
  element: T,
  children: U[]
): Container<T, U[]>;Try

有了泛型参数默认值,我们可以将其简化为:

image-20260104231413215

泛型参数默认值遵循以下规则:

  • 如果一个类型参数具有默认值,则该参数被视为可选的。
  • 必选的类型参数不能跟在可选类型参数后面。
  • 类型参数的默认类型必须满足该类型参数的约束(如果存在)。
  • 在指定类型实参时,你只需要为必选的类型参数指定类型实参。未指定的类型参数将解析为它们的默认类型。
  • 如果指定了默认类型且推断无法选择候选类型,则会推断为默认类型。
  • 一个与现有类或接口声明合并的类或接口声明可以为现有类型参数引入默认值。
  • 一个与现有类或接口声明合并的类或接口声明可以引入新的类型参数,只要它指定了默认值。

方差注解 (Variance Annotations)

这是一个用于解决非常特定高级问题的特性,仅在你确定有理由使用它时才应使用。

协变与逆变 (Covariance and contravariance) 是类型理论中的术语,用于描述两个泛型类型之间的关系。以下是该概念的简要入门。

例如,如果你有一个表示可以 make(产生)某种类型的对象的接口:

interface Producer<T> {
  make(): T;
}

我们可以在预期使用 Producer<Animal> 的地方使用 Producer<Cat>,因为 CatAnimal。这种关系被称为协变(covariance):从 Producer<T>Producer<U> 的关系与从 TU 的关系相同。

反之,如果你有一个可以 consume(消耗)某种类型的接口:

interface Consumer<T> {
  consume: (arg: T) => void;
}

那么我们可以在预期使用 Consumer<Cat> 的地方使用 Consumer<Animal>,因为任何能够接受 Animal 的函数也必然能够接受 Cat。这种关系被称为逆变(contravariance):从 Consumer<T>Consumer<U> 的关系与从 UT 的关系相反。注意与协变相比,方向发生了反转!这就是为什么逆变会“抵消自身”,而协变不会。

在像 TypeScript 这样基于结构的类型系统中,协变和逆变是从类型定义中自然产生的行为。即使没有泛型,我们也会看到协变(和逆变)关系:

interface AnimalProducer {
  make(): Animal;
}

// 在预期使用 Animal 生产者的地方,可以使用 Cat 生产者
interface CatProducer {
  make(): Cat;
}

TypeScript 采用结构化类型系统,因此在比较两个类型时(例如查看 Producer<Cat> 是否可以用于预期为 Producer<Animal> 的地方),通常的算法会结构化地展开这两个定义并比较它们的结构。然而,方差允许进行极具价值的优化:如果 Producer<T>T 是协变的,那么我们可以直接检查 CatAnimal,因为我们知道它们的关系与 Producer<Cat>Producer<Animal> 相同。

请注意,这种逻辑仅在我们检查同一类型的两个实例化时才能使用。如果我们有一个 Producer<T> 和一个 FastProducer<U>,无法保证 TU 必然引用这些类型中的相同位置,因此这种检查将始终通过结构化方式进行。

因为方差是结构化类型自然产生的属性,TypeScript 会自动推断每个泛型类型的方差。在极少数情况下,涉及某些循环类型时,这种测量可能会不准确。如果发生这种情况,你可以为类型参数添加方差注解,以强制指定特定的方差:

// 逆变注解 (Contravariant annotation)
interface Consumer<in T> {
  consume: (arg: T) => void;
}

// 协变注解 (Covariant annotation)
interface Producer<out T> {
  make(): T;
}

// 不变注解 (Invariant annotation)
interface ProducerConsumer<in out T> {
  consume: (arg: T) => void;
  make(): T;
}

仅当你编写的方差与结构化本应发生的方差相同时才这样做。

永远不要编写与结构化方差不匹配的方差注解!

需要特别强调的是,方差注解仅在基于实例化的比较过程中生效。它们在结构化比较过程中没有任何作用。例如,你不能使用方差注解来“强制”一个类型实际上是不变的:

// 不要这样做 - 方差注解与结构化行为不匹配
interface Producer<in out T> {
  make(): T;
}

// 不是类型错误 -- 这是一个结构化比较,
// 因此方差注解不生效
const p: Producer<string | number> = {
    make(): number {
        return 42;
    }
}

在这里,对象字面量的 make 函数返回 number,我们本预期这会报错,因为 number 并不是 string | number。然而,这并不是基于实例化的比较,因为对象字面量是一个匿名类型,而不是 Producer<string | number>

方差注解不会改变结构化行为,且仅在特定情况下被参考。

只有当你绝对清楚自己为什么要这样做、它们的局限性是什么以及何时它们不生效时,编写方差注解才非常重要。TypeScript 使用基于实例化的比较还是结构化比较并不是确定的行为,并且可能出于正确性或性能原因在版本之间发生变化。因此,你只应在方差注解与类型的结构化行为相匹配时编写它们。不要尝试使用方差注解来“强制”特定的方差;这会导致代码出现不可预测的行为。

除非方差注解与类型的结构化行为匹配,否则切勿编写方差注解。

请记住,TypeScript 可以从你的泛型类型中自动推断方差。编写方差注解几乎从未必要,你只有在识别出特定需求时才应这样做。方差注解不会改变类型的结构化行为,且根据情况,你可能会看到在预期进行实例化比较时执行了结构化比较。方差注解不能用于修改类型在这些结构化上下文中的行为,并且不应编写,除非注解与结构化定义相同。由于这很难做对,且 TypeScript 在绝大多数情况下都能正确推断方差,因此你不应该在常规代码中编写方差注解。

不要尝试使用方差注解来改变类型检查行为;这不是它们的用途。

可能会发现临时方差注解在“类型调试”情况下很有用,因为方差注解是会被检查的。如果注解的方差明显错误,TypeScript 会报错:

// 错误,此接口对 T 绝对是逆变的
interface Foo<out T> {
  consume: (arg: T) => void;
}

然而,方差注解允许更严格(例如,如果实际方差是协变的,in out 是有效的)。完成调试后请务必移除方差注解。

最后,如果你正尝试最大化类型检查性能,并且运行了分析器(profiler),识别出了特定的慢类型,识别出方差推断特别慢,仔细验证了你想编写的方差注解,那么在极其复杂的类型中添加方差注解可能会看到微小的性能提升。

不要尝试使用方差注解来改变类型检查行为;这不是它们的用途。

Keyof 类型运算符

keyof 类型运算符

keyof 运算符接受一个对象类型,并生成其键的字符串或数字字面量联合。以下类型 Ptype P = "x" | "y" 的类型相同:

image-20260104231743122

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

image-20260104231757067

请注意,在此示例中,Mstring | number——这是因为 JavaScript 对象键总是被强制转换为字符串,所以 obj[0] 总是与 obj["0"] 相同。

keyof 类型与映射类型结合使用时特别有用,我们稍后将详细了解它们。

Typeof 类型运算符

typeof 类型运算符

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

// 打印 "string"
console.log(typeof "Hello world");Try

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

image-20260104231818365

这对于基本类型来说用处不大,但结合其他类型运算符,你可以使用 typeof 方便地表达许多模式。举个例子,让我们先看看预定义类型 ReturnType<T>。它接受一个函数类型并生成其返回类型:

image-20260104231835783

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

image-20260104232138145

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

image-20260104232147889

局限性

TypeScript 有意限制你可以对哪些表达式使用 typeof

具体来说,只允许对标识符(即变量名)或其属性使用 typeof。这有助于避免编写你认为正在执行但实际上并非如此的代码所造成的令人困惑的陷阱:

image-20260104232211501

索引访问类型

我们可以使用**索引访问类型(indexed access type)**来查找另一个类型上的特定属性:

image-20260104232236757

索引类型本身也是一个类型,因此我们可以使用联合类型、keyof 或其他完整的类型:

image-20260104232243043

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

image-20260104232310408

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

image-20260104232323299

你只能在索引时使用类型,这意味着你不能使用 const 来创建变量引用:

image-20260104232337785

但是,你可以使用类型别名来实现类似的重构风格:

ts
type key = "age";
type Age = Person[key];

条件类型

在大多数有用的程序核心,我们都必须根据输入做出决策。JavaScript 程序也不例外,但鉴于值可以轻松地进行自省,这些决策也基于输入的类型。**条件类型(Conditional types)**有助于描述输入和输出类型之间的关系。

image-20260104232408680

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

  SomeType extends OtherType ? TrueType : FalseType;Try

extends 左侧的类型可赋值给右侧的类型时,你将得到第一个分支中的类型(“真”分支);否则你将得到后一个分支中的类型(“假”分支)。

从上面的例子来看,条件类型可能不会立即显得有用——我们可以自己判断 Dog 是否 extends Animal,然后选择 numberstring!但条件类型的强大之处在于与泛型结合使用。

例如,让我们看看下面的 createLabel 函数:

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";
}Try

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

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

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

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

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

image-20260104232423200

条件类型约束

通常,条件类型中的检查会为我们提供一些新信息。就像使用类型守卫进行类型缩小可以给我们一个更具体的类型一样,条件类型的真分支将通过我们检查的类型进一步约束泛型。

例如,让我们看看下面的:

image-20260104232433882

在这个例子中,TypeScript 报错是因为 T 未知具有名为 message 的属性。我们可以约束 T,TypeScript 就不会再抱怨了:

image-20260104232446628

然而,如果我们希望 MessageOf 接受任何类型,并且如果 message 属性不可用,则默认为 never 怎么办?我们可以通过将约束移出并引入条件类型来实现这一点:

image-20260104232457546

在真分支中,TypeScript 知道 T 具有 message 属性。

再举一个例子,我们还可以编写一个名为 Flatten 的类型,它将数组类型扁平化为它们的元素类型,否则保持不变:

image-20260104232509082

Flatten 获得一个数组类型时,它使用 number 进行索引访问,以获取 string[] 的元素类型。否则,它只返回它被赋予的类型。

在条件类型中推断

我们刚刚发现自己使用条件类型来应用约束然后提取类型。这最终成为一个如此常见的操作,以至于条件类型使其更容易。

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

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

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

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

image-20260104232535023

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

image-20260104232547497

分布式条件类型

当条件类型作用于泛型类型时,如果给定联合类型,它们将变为分布式(distributive)。例如,请看以下内容:

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

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

image-20260104232557448

这里发生的情况是 ToArray 分布在:

  string | number;Try

并映射联合的每个成员类型,以有效地变为:

  ToArray<string> | ToArray<number>;Try

这使我们得到:

  string[] | number[];Try

通常,分布性是期望的行为。为了避免这种行为,你可以用方括号将 extends 关键字的两侧括起来。

image-20260104232607781

映射类型

当你不想重复编写内容时,有时某个类型需要基于另一个类型来构建。

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

type OnlyBoolsAndHorses = {
  [key: string]: boolean | Horse;
};
 
const conforms: OnlyBoolsAndHorses = {
  del: true,
  rodney: false,
};Try

映射类型是一种泛型类型,它使用 PropertyKey 的联合类型(通常通过 keyof 创建)遍历键,从而创建一个新类型:

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

在这个例子中,OptionsFlags 会获取类型 Type 的所有属性,并将它们的值类型改为 boolean

image-20260104232621205

映射修饰符

在映射过程中还可以应用两个额外的修饰符:readonly?,它们分别影响可变性和可选性。

你可以通过加上前缀 -+ 来移除或添加这些修饰符。如果不写前缀,则默认使用 +

image-20260104232630377

image-20260104232638836

通过 as 重新映射键

从 TypeScript 4.1 开始,你可以在映射类型中使用 as 子句重新映射键:

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

你可以利用模板字面量类型之类的特性,从已有属性名生成新的属性名:

image-20260104232652606

你还可以通过条件类型产出 never 来过滤掉某些键:

image-20260104232701583

你可以映射任意联合类型,而不仅仅是 string | number | symbol 的联合,也可以是任意类型的联合:

image-20260104232713824

进一步探索

映射类型与本节“类型操作”中的其他特性配合得很好。比如,这里有一个使用条件类型的映射类型,它会根据对象的 pii 属性是否被设置为字面量 true 来返回 truefalse

image-20260104232728214

模板字面量类型

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

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

image-20260104232739786

当联合类型用于插值位置时,该类型是每个联合成员可以表示的所有可能的字符串字面量的集合:

image-20260104232752397

对于模板字面量中的每个插值位置,联合类型都会进行交叉相乘:

image-20260104232805251

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

类型中的字符串联合

模板字面量的强大之处在于根据类型中的信息定义新字符串。

考虑一个函数 (makeWatchedObject) 将一个名为 on() 的新函数添加到传递的对象中。在 JavaScript 中,它的调用可能看起来像:makeWatchedObject(baseObject)。我们可以想象基本对象看起来像:

const passedObject = {
  firstName: "Saoirse",
  lastName: "Ronan",
  age: 26,
};Try

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

eventName 应该采用 attributeInThePassedObject + "Changed" 的形式;因此,firstNameChanged 是从基本对象中的 firstName 属性派生出来的。

callback 函数在调用时:

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

on() 的天真函数签名可能因此是:on(eventName: string, callback: (newValue: any) => void)。然而,在前面的描述中,我们确定了我们希望在代码中记录的重要类型约束。模板字面量类型允许我们将这些约束引入到代码中。

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}!`);
});Try

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

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>;Try

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

image-20260104232832509

模板字面量推断

请注意,我们没有从原始传递对象中提供的所有信息中受益。给定 firstName 的更改(即 firstNameChanged 事件),我们应该期望回调将接收一个 string 类型参数。类似地,age 更改的回调应该接收一个 number 参数。我们天真地使用 any 来为 callback 的参数类型化。同样,模板字面量类型使得确保属性的数据类型将与该属性回调的第一个参数类型相同成为可能。

实现这一点的关键在于:我们可以使用一个带有泛型的函数,使得:

  1. 第一个参数中使用的字面量被捕获为字面量类型
  2. 该字面量类型可以被验证为存在于泛型中有效属性的联合中
  3. 验证属性的类型可以使用索引访问在泛型的结构中查找
  4. 然后可以将此类型信息应用于确保回调函数的参数具有相同的类型

image-20260104232847658

这里我们将 on 转换为泛型方法。

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

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

内置字符串操作类型

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

Uppercase<StringType>

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

示例

image-20260104232900105

Lowercase<StringType>

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

示例

image-20260104232911038

Capitalize<StringType>

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

示例

image-20260104232930225

Uncapitalize<StringType>

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

示例

image-20260104232942278

背景阅读: 类 (MDN)

TypeScript 完全支持 ES2015 引入的 class 关键字。

和其他 JavaScript 语言特性一样,TypeScript 添加了类型注解以及其他语法,使你能够表达类与其他类型之间的关系。

类成员

这是最基本的类,一个空类:

class Point {}Try

这个类现在还不是很实用,让我们开始添加一些成员。

字段

字段声明在类上创建了一个公共的可写属性:

class Point {
  x: number;
  y: number;
}
 
const pt = new Point();
pt.x = 0;
pt.y = 0;Try

与其他位置一样,类型注解是可选的,但如果未指定,则将是隐式的 any

字段也可以有初始化器;这些将在类实例化时自动运行:

class Point {
  x = 0;
  y = 0;
}
 
const pt = new Point();
// 打印 0, 0
console.log(`${pt.x}, ${pt.y}`);Try

就像 constletvar 一样,类属性的初始化器将用于推断其类型:

image-20260104233045716

--strictPropertyInitialization

strictPropertyInitialization 这个设置用于控制类字段是否必须在构造函数中完成初始化。

image-20260104233056694

class GoodGreeter {
  name: string;
 
  constructor() {
    this.name = "hello";
  }
}Try

请注意,字段必须直接在构造函数内部完成初始化。TypeScript 不会分析你在构造函数中调用的方法来判断是否完成了初始化,因为派生类可能会重写这些方法,导致成员没有被正确初始化。

如果你确定会通过构造函数之外的方式初始化某个字段(例如某个外部库会替你填充类的一部分),你可以使用明确赋值断言运算符 !

class OKGreeter {
  // Not initialized, but no error
  name!: string;
}Try

readonly

字段可以加上 readonly 修饰符前缀。这会阻止在构造函数之外对该字段赋值。

image-20260104233111654

构造函数

背景阅读: 构造函数 (MDN)

类的构造函数和普通函数非常相似。你可以为它添加带类型注解的参数、默认值以及重载:

class Point {
  x: number;
  y: number;
 
  // Normal signature with defaults
  constructor(x = 0, y = 0) {
    this.x = x;
    this.y = y;
  }
}Try
class Point {
  x: number = 0;
  y: number = 0;
 
  // Constructor overloads
  constructor(x: number, y: number);
  constructor(xy: string);
  constructor(x: string | number, y: number = 0) {
    // Code logic here
  }
}Try

类构造函数签名和函数签名之间只有少数几个区别:

  • 构造函数不能拥有类型参数,这些类型参数应写在外层类声明上,我们稍后会看到
  • 构造函数不能写返回类型注解,因为它始终返回该类的实例类型
super 调用

和 JavaScript 一样,如果你有一个基类,那么在构造函数体中使用任何 this. 成员之前,都需要先调用 super();

image-20260104233139959

在 JavaScript 中,忘记调用 super 是一个很常见的错误,但 TypeScript 会在必须调用它的时候提醒你。

方法

背景阅读: 方法定义

类上的函数属性称为方法。方法可以使用和函数、构造函数相同的各种类型注解:

class Point {
  x = 10;
  y = 10;
 
  scale(n: number): void {
    this.x *= n;
    this.y *= n;
  }
}Try

除了这些标准类型注解之外,TypeScript 并没有为方法增加其他新东西。

请注意,在方法体内部,访问字段和其他方法时仍然必须通过 this.。方法体中的未限定名称始终会引用外层作用域中的某个东西:

image-20260104233206181

Getter / Setter

类也可以拥有访问器

class C {
  _length = 0;
  get length() {
    return this._length;
  }
  set length(value) {
    this._length = value;
  }
}Try

注意,在 JavaScript 中,如果一对 getter/setter 只是简单地包装字段而没有额外逻辑,通常并没有太大意义。如果你不需要在 get/set 操作中加入额外逻辑,直接暴露公共字段就可以了。

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

  • 如果存在 get 但不存在 set,那么该属性会被自动推断为 readonly
  • 如果没有显式指定 setter 参数的类型,那么它会从 getter 的返回类型中推断出来

TypeScript 4.3 开始,getter 和 setter 已经可以拥有不同的类型。

class Thing {
  _size = 0;
 
  get size(): number {
    return this._size;
  }
 
  set size(value: string | number | boolean) {
    let num = Number(value);
 
    // Don't allow NaN, Infinity, etc
 
    if (!Number.isFinite(num)) {
      this._size = 0;
      return;
    }
 
    this._size = num;
  }
}Try

索引签名

类也可以声明索引签名;它们和其他对象类型中的索引签名工作方式相同:

class MyClass {
  [s: string]: boolean | ((s: string) => boolean);
 
  check(s: string) {
    return this[s] as boolean;
  }
}Try

由于索引签名的类型还需要同时覆盖方法的类型,因此这类写法通常不太容易被有效利用。一般来说,更好的做法是把索引数据存放在类实例之外的其他位置。

类继承

和其他支持面向对象特性的语言一样,JavaScript 中的类也可以继承基类。

implements 子句

你可以使用 implements 子句来检查一个类是否满足某个特定的 interface。如果类没有正确实现它,就会报错:

image-20260104233220810

类也可以同时实现多个接口,例如 class C implements A, B {

注意事项

理解这一点非常重要:implements 子句只是在检查该类能否被当作接口类型来使用。它完全不会改变这个类或其方法的类型。一个常见错误是误以为 implements 会修改类的类型,但其实不会!

image-20260104233231366

在这个例子中,我们也许会以为 s 的类型会受到 checkname: string 参数的影响。其实并不会,implements 子句不会改变类体的检查方式,也不会改变类型推断结果。

同样地,实现一个带有可选属性的接口,并不会自动创建这个属性:

image-20260104233241557

extends 子句

背景阅读: extends 关键字 (MDN)

类可以通过 extends 继承基类。派生类拥有基类的所有属性和方法,也可以定义额外的成员。

class Animal {
  move() {
    console.log("Moving along!");
  }
}
 
class Dog extends Animal {
  woof(times: number) {
    for (let i = 0; i < times; i++) {
      console.log("woof!");
    }
  }
}
 
const d = new Dog();
// Base class method
d.move();
// Derived class method
d.woof(3);Try
重写方法

背景阅读: super 关键字 (MDN)

派生类也可以重写基类的字段或属性。你可以使用 super. 语法访问基类的方法。请注意,由于 JavaScript 的类本质上只是简单的查找对象,因此并不存在“super 字段”这一概念。

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()}`);
    }
  }
}
 
const d = new Derived();
d.greet();
d.greet("reader");Try

派生类遵守基类约定非常重要。请记住,通过基类引用来引用派生类实例是非常常见的做法(而且始终合法!):

// Alias the derived instance through a base class reference
const b: Base = d;
// No problem
b.greet();Try

如果 Derived 没有遵守 Base 的约定会怎样?

image-20260104233255984

如果我们无视这个错误仍然编译代码,那么这个示例就会在运行时报错:

const b: Base = new Derived();
// Crashes because "name" will be undefined
b.greet();Try
仅类型字段声明

target >= ES2022useDefineForClassFieldstrue 时,类字段会在父类构造函数执行完成后才初始化,从而覆盖父类设置的任何值。当你只是想为继承而来的字段重新声明一个更精确的类型时,这就可能带来问题。为了解决这种情况,你可以写上 declare,告诉 TypeScript 这个字段声明不应产生任何运行时效果。

interface Animal {
  dateOfBirth: any;
}
 
interface Dog extends Animal {
  breed: any;
}
 
class AnimalHouse {
  resident: Animal;
  constructor(animal: Animal) {
    this.resident = animal;
  }
}
 
class DogHouse extends AnimalHouse {
  // Does not emit JavaScript code,
  // only ensures the types are correct
  declare resident: Dog;
  constructor(dog: Dog) {
    super(dog);
  }
}Try
初始化顺序

JavaScript 类的初始化顺序在某些情况下会让人感到意外。来看下面这段代码:

class Base {
  name = "base";
  constructor() {
    console.log("My name is " + this.name);
  }
}
 
class Derived extends Base {
  name = "derived";
}
 
// Prints "base", not "derived"
const d = new Derived();Try

这里发生了什么?

JavaScript 定义的类初始化顺序如下:

  • 初始化基类字段
  • 执行基类构造函数
  • 初始化派生类字段
  • 执行派生类构造函数

这意味着在基类构造函数执行期间,它看到的 name 还是自己的值,因为派生类的字段初始化此时尚未执行。

继承内置类型

注意:如果你不打算继承 ArrayErrorMap 等内置类型,或者你的编译目标已经明确设置为 ES6/ES2015 及以上,那么可以跳过本节

在 ES2015 中,那些返回对象的构造函数会对 super(...) 的调用者隐式替换 this 的值。因此,生成的构造函数代码必须捕获 super(...) 的潜在返回值,并用它替换 this

因此,继承 ErrorArray 等类型时,行为可能不再符合预期。这是因为 ErrorArray 之类的构造函数会使用 ECMAScript 6 的 new.target 来调整原型链;而在 ECMAScript 5 中调用构造函数时,没有办法保证 new.target 的值。其他降级编译器通常默认也有同样的限制。

对于下面这样的子类:

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

你可能会发现:

  • 由这些子类构造出来的对象上,方法可能是 undefined,因此调用 sayHello 会报错。
  • 子类实例与其构造函数之间的 instanceof 判断也会失效,所以 (new MsgError()) instanceof MsgError 会返回 false

通常建议你在每次调用 super(...) 之后,手动调整原型。

class MsgError extends Error {
  constructor(m: string) {
    super(m);
 
    // Set the prototype explicitly.
    Object.setPrototypeOf(this, MsgError.prototype);
  }
 
  sayHello() {
    return "hello " + this.message;
  }
}Try

不过,MsgError 的任何子类也都必须手动设置原型。对于不支持 Object.setPrototypeOf 的运行时环境,你也许可以改用 __proto__

遗憾的是,这些变通办法在 Internet Explorer 10 及更早版本中都无法生效。你可以手动把原型上的方法复制到实例本身上(例如把 MsgError.prototype 复制到 this),但原型链本身无法被修复。

成员可见性

你可以使用 TypeScript 来控制某些方法或属性是否能被类外部的代码访问。

public

类成员默认的可见性是 publicpublic 成员可以在任何地方访问:

class Greeter {
  public greet() {
    console.log("hi!");
  }
}
const g = new Greeter();
g.greet();Try

由于 public 本来就是默认可见性修饰符,所以你其实并不需要在类成员上显式写它;不过出于风格或可读性考虑,你也可以选择写上。

protected

protected 成员只对声明它的类及其子类可见。

image-20260104233312384

暴露 protected 成员

派生类需要遵守基类约定,但也可以选择暴露一个功能更强的基类子类型。这也包括把 protected 成员改为 public

class Base {
  protected m = 10;
}
class Derived extends Base {
  // No modifier, so default is 'public'
  m = 15;
}
const d = new Derived();
console.log(d.m); // OKTry

请注意,Derived 原本就已经可以自由读写 m,所以这并不会真正改变这里的“安全性”。这里真正需要注意的是:如果这种暴露并非有意为之,那么在派生类中应当小心地继续写上 protected 修饰符。

跨层级的 protected 访问

TypeScript 不允许在类继承层级中访问兄弟类的 protected 成员:

image-20260104233323297

这是因为,对 Derived2 中的 x 的访问,只应当对 Derived2 的子类合法,而 Derived1 并不是其中之一。此外,如果通过 Derived1 的引用访问 x 是非法的(它当然应该是非法的!),那么通过基类引用访问它也不应让情况变得合法。

另见 Why Can’t I Access A Protected Member From A Derived Class?,其中进一步解释了 C# 在这个问题上的一些设计理由。

private

privateprotected 类似,但它甚至不允许子类访问该成员:

image-20260104233332535

image-20260104233338738

由于 private 成员对子类不可见,因此派生类也不能提升它们的可见性:

image-20260104233348691

跨实例的 private 访问

不同的面向对象语言对于“同一个类的不同实例之间能否访问彼此的 private 成员”有不同看法。Java、C#、C++、Swift 和 PHP 允许这样做,而 Ruby 不允许。

TypeScript 允许跨实例访问 private 成员:

class A {
  private x = 10;
 
  public sameAs(other: A) {
    // No error
    return other.x === this.x;
  }
}Try
注意事项

和 TypeScript 类型系统的其他部分一样,privateprotected 只会在类型检查阶段生效

这意味着,JavaScript 运行时中的 in 操作符或普通属性查找,仍然可以访问 privateprotected 成员:

class MySafe {
  private secretKey = 12345;
}Try
// In a JavaScript file...
const s = new MySafe();
// Will print 12345
console.log(s.secretKey);

在类型检查期间,private 还允许通过方括号记法访问。这让 private 声明的字段在单元测试等场景下更容易访问,但代价是这些字段只是软私有,并不能严格保证隐私性。

image-20260104233402551

与 TypeScript 的 private 不同,JavaScript 的私有字段#)在编译后仍然保持私有,也不会提供前面提到的诸如方括号访问之类的逃逸方式,因此它们属于硬私有

class Dog {
  #barkAmount = 0;
  personality = "happy";
 
  constructor() {}
}Try
"use strict";
class Dog {
    #barkAmount = 0;
    personality = "happy";
    constructor() { }
}
 Try

当编译目标为 ES2021 或更低版本时,TypeScript 会使用 WeakMap 来替代 #

"use strict";
var _Dog_barkAmount;
class Dog {
    constructor() {
        _Dog_barkAmount.set(this, 0);
        this.personality = "happy";
    }
}
_Dog_barkAmount = new WeakMap();
 Try

如果你需要防止类中的值被恶意访问者获取,那么应当使用那些能在运行时提供硬隐私保护的机制,比如闭包、WeakMap 或私有字段。要注意,这些运行时的额外隐私检查可能会影响性能。

静态成员

背景阅读: 静态成员 (MDN)

类可以拥有 static 成员。这些成员不属于某个特定实例,而是可以通过类构造函数对象本身来访问:

class MyClass {
  static x = 0;
  static printX() {
    console.log(MyClass.x);
  }
}
console.log(MyClass.x);
MyClass.printX();Try

静态成员同样可以使用 publicprotectedprivate 这些可见性修饰符:

image-20260104233414511

静态成员同样会被继承:

class Base {
  static getGreeting() {
    return "Hello world";
  }
}
class Derived extends Base {
  myGreeting = Derived.getGreeting();
}Try

特殊的静态名称

通常来说,覆盖 Function 原型上的属性既不安全,也往往不可行。由于类本身也是可以通过 new 调用的函数,因此某些 static 名称不能使用。像 namelengthcall 这样的函数属性就不能被定义为 static 成员:

image-20260104233423372

为什么没有静态类?

TypeScript(以及 JavaScript)不像 C# 那样存在名为 static class 的语法结构。

这种结构之所以存在,只是因为那些语言强制所有数据和函数都必须放在类里面;而 TypeScript 并没有这种限制,所以也就不需要它。一个只会有单个实例的“类”,在 JavaScript/TypeScript 中通常直接表示为普通对象即可。

例如,在 TypeScript 中我们不需要“静态类”语法,因为普通对象(甚至顶层函数)就足以胜任:

// Unnecessary "static" class
class MyStaticClass {
  static doSomething() {}
}
 
// Preferred (alternative 1)
function doSomething() {}
 
// Preferred (alternative 2)
const MyHelperObject = {
  dosomething() {},
};Try

类中的 static 代码块

static 代码块允许你编写一组拥有自身作用域的语句,并且这些语句可以访问所在类中的私有字段。这意味着我们可以编写初始化代码,同时拥有完整的语句表达能力、不泄漏变量,并且可以完全访问类的内部细节。

class Foo {
    static #count = 0;
 
    get count() {
        return Foo.#count;
    }
 
    static {
        try {
            const lastInstances = loadLastInstances();
            Foo.#count += lastInstances.length;
        }
        catch {}
    }
}Try

泛型类

和接口一样,类也可以是泛型的。当使用 new 实例化泛型类时,它的类型参数会像函数调用一样被推断:

image-20260104233439718

类可以像接口一样使用泛型约束和默认类型参数。

静态成员中的类型参数

下面这段代码是不合法的,而且原因可能并不直观:

image-20260104233449540

请记住,类型始终会被完全擦除!在运行时,只存在一个 Box.defaultValue 属性槽。这意味着如果可以设置 Box<string>.defaultValue,那么它也会同时改变 Box<number>.defaultValue,这显然不合理。因此,泛型类的 static 成员永远不能引用类的类型参数。

类运行时中的 this

背景阅读: this 关键字 (MDN)

要记住一个重要事实:TypeScript 不会改变 JavaScript 的运行时行为,而 JavaScript 恰恰以某些有点“古怪”的运行时行为而闻名。

JavaScript 对 this 的处理方式确实很特别:

class MyClass {
  name = "MyClass";
  getName() {
    return this.name;
  }
}
const c = new MyClass();
const obj = {
  name: "obj",
  getName: c.getName,
};
 
// Prints "obj", not "MyClass"
console.log(obj.getName());Try

简单来说,默认情况下,函数内部 this 的值取决于函数是如何被调用的。在这个例子里,由于函数是通过 obj 引用调用的,因此它的 thisobj,而不是类实例。

这通常不是你想要的结果!TypeScript 提供了一些办法来减轻或避免这类错误。

箭头函数

背景阅读: 箭头函数 (MDN)

如果某个函数经常会以丢失 this 上下文的方式被调用,那么相比方法定义,使用箭头函数属性可能更合理:

class MyClass {
  name = "MyClass";
  getName = () => {
    return this.name;
  };
}
const c = new MyClass();
const g = c.getName;
// Prints "MyClass" instead of crashing
console.log(g());Try

这样做有一些取舍:

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

this 参数

在方法或函数定义中,名为 this 的首个参数在 TypeScript 中有特殊含义。这些参数会在编译时被擦除:

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

TypeScript 会检查:带有 this 参数的函数是否在正确的上下文中被调用。与其使用箭头函数,我们也可以给方法定义加上 this 参数,从而在静态层面强制方法被正确调用:

image-20260104233505612

这种做法与箭头函数方案正好有相反的取舍:

  • JavaScript 调用方仍然可能在不知情的情况下错误地使用类方法
  • 每个类定义只会分配一个函数,而不是每个实例各分配一个
  • 仍然可以通过 super 调用基类方法定义。

this 类型

在类中,有一种特殊类型叫做 this,它会动态地指向当前类的类型。来看它为什么有用:

image-20260104233515613

这里,TypeScript 将 set 的返回类型推断为 this,而不是 Box。现在让我们为 Box 创建一个子类:

image-20260104233525297

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

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

这和写成 other: Box 不同。如果你有一个派生类,那么它的 sameAs 方法现在只会接受该派生类自己的其他实例:

image-20260104233535931

基于 this 的类型守卫

你可以在类和接口的方法返回位置使用 this is Type。当它与类型缩小(例如 if 语句)结合使用时,目标对象的类型就会被缩小为指定的 Type

image-20260104233600934

基于 this 的类型守卫一个常见用途,是对某个字段进行延迟校验。例如,在下面这个例子中,当 hasValue 被验证为真时,就会从 box 内部持有的值类型中移除 undefined

image-20260104233612212

参数属性

TypeScript 提供了一种特殊语法,可以把构造函数参数直接转换为同名同值的类属性。这些属性叫做参数属性,其写法是在构造函数参数前加上 publicprivateprotectedreadonly 这些可见性修饰符之一。生成的字段也会带上对应修饰符:

image-20260104233627946

类表达式

背景阅读: 类表达式 (MDN)

类表达式和类声明非常相似。唯一真正的区别在于,类表达式不一定需要名字,不过我们仍然可以通过它最终绑定到的标识符来引用它:

image-20260104233639936

构造签名

JavaScript 中的类通过 new 运算符实例化。给定一个类本身的类型后,InstanceType 工具类型可以对这个操作进行建模。

class Point {
  createdAt: number;
  x: number;
  y: number
  constructor(x: number, y: number) {
    this.createdAt = Date.now()
    this.x = x;
    this.y = y;
  }
}
type PointInstance = InstanceType<typeof Point>
 
function moveRight(point: PointInstance) {
  point.x += 5;
}
 
const point = new Point(3, 4);
moveRight(point);
point.x; // => 8Try

abstract 类与成员

在 TypeScript 中,类、方法和字段都可以是抽象的

抽象方法抽象字段指的是尚未提供实现的成员。这些成员必须存在于抽象类中,而抽象类本身不能被直接实例化。

抽象类的作用,是作为那些会实现全部抽象成员的子类的基类。当一个类不包含任何抽象成员时,它就被称为具体类

来看一个例子:

image-20260104233653016

由于 Base 是抽象类,所以我们不能使用 new 来实例化它。相反,我们需要创建一个派生类并实现这些抽象成员:

class Derived extends Base {
  getName() {
    return "world";
  }
}
 
const d = new Derived();
d.printName();Try

请注意,如果我们忘记实现基类中的抽象成员,就会报错:

image-20260104233706162

抽象构造签名

有时候,你希望接收某个类的构造函数,而这个构造函数产生的实例是从某个抽象类派生出来的。

例如,你可能想写出这样的代码:

image-20260104233718036

TypeScript 会正确地告诉你:你正在尝试实例化一个抽象类。毕竟,根据 greet 的定义,下面这段代码在语法上完全合法,而它最终会尝试构造一个抽象类:

// Bad!
greet(Base);Try

你真正想写的,是一个接收“具有构造签名的类型”的函数:

image-20260104233728292

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

类之间的关系

在大多数情况下,TypeScript 中的类和其他类型一样,采用结构化方式进行比较。

例如,下面这两个类可以相互替代使用,因为它们的结构完全相同:

class Point1 {
  x = 0;
  y = 0;
}
 
class Point2 {
  x = 0;
  y = 0;
}
 
// OK
const p: Point1 = new Point2();Try

同样地,即使没有显式继承,类之间仍然可能存在子类型关系:

class Person {
  name: string;
  age: number;
}
 
class Employee {
  name: string;
  age: number;
  salary: number;
}
 
// OK
const p: Person = new Employee();Try

这听起来很直接,但其中仍有一些情况会显得比较反直觉。

空类没有任何成员。在结构类型系统中,没有成员的类型通常是所有其他类型的超类型。所以如果你写了一个空类(别这么做!),那么几乎任何东西都可以替代它:

class Empty {}
 
function fn(x: Empty) {
  // can't do anything with 'x', so I won't
}
 
// All OK!
fn(window);
fn({});
fn(fn);

模块

JavaScript 在处理代码模块化方面有着很长的历史,曾经出现过多种不同方式。自 2012 年以来,TypeScript 已经实现了对其中许多格式的支持,但随着时间推移,社区和 JavaScript 规范逐渐收敛到一种叫做 ES Modules(也称 ES6 modules)的格式上。你可能更熟悉它的 import / export 语法。

ES Modules 于 2015 年被加入 JavaScript 规范,到 2020 年时已经在大多数浏览器和 JavaScript 运行时中获得了广泛支持。

为了聚焦重点,本手册会介绍 ES Modules 以及它之前非常流行的 CommonJS module.exports = 语法。至于其他模块模式,你可以在参考章节中的 Modules 页面找到更多信息。

JavaScript 模块是如何定义的

在 TypeScript 中,和 ECMAScript 2015 一样,任何包含顶层 importexport 的文件都会被视为一个模块。

相反,不包含任何顶层 importexport 声明的文件会被视为脚本,其内容会处于全局作用域中(因此模块中也能访问到它们)。

模块是在各自独立的作用域中执行的,而不是在全局作用域中执行。这意味着,在模块中声明的变量、函数、类等,如果没有通过某种导出形式显式导出,在模块外部就不可见。反过来,如果要使用其他模块导出的变量、函数、类、接口等内容,就必须通过某种导入形式将其导入。

非模块

在开始之前,理解 TypeScript 如何判断一个文件是不是模块非常重要。JavaScript 规范规定:任何不包含 import 声明、export,或顶层 await 的 JavaScript 文件,都应当被视为脚本而不是模块。

在脚本文件内部,变量和类型会被声明在共享的全局作用域中。通常这意味着你要么使用 outFile 编译选项把多个输入文件合并成一个输出文件,要么在 HTML 中通过多个 <script> 标签按正确顺序加载这些文件。

如果你有一个文件当前没有任何 importexport,但你希望它被当作模块处理,那么可以加上一行:

export {};Try

这样就会把该文件变成一个“不导出任何内容”的模块。无论你的模块目标是什么,这种语法都可以使用。

TypeScript 中的模块

延伸阅读: Impatient JS(模块) MDN: JavaScript Modules

在 TypeScript 中编写基于模块的代码时,主要需要考虑三件事:

  • 语法:我想使用什么语法来导入和导出内容?
  • 模块解析:模块名(或路径)与磁盘上的文件之间是什么关系?
  • 模块输出目标:最终生成的 JavaScript 模块应该长什么样?

ES 模块语法

文件可以通过 export default 声明一个默认导出:

// @filename: hello.ts
export default function helloWorld() {
  console.log("Hello, world!");
}Try

然后可以这样导入它:

import helloWorld from "./hello.js";
helloWorld();Try

除了默认导出之外,你还可以通过省略 defaultexport 导出多个变量和函数:

// @filename: maths.ts
export var pi = 3.14;
export let squareTwo = 1.41;
export const phi = 1.61;
 
export class RandomNumberGenerator {}
 
export function absolute(num: number) {
  if (num < 0) return num * -1;
  return num;
}Try

这些内容可以在另一个文件中通过 import 语法来使用:

image-20260104233807938

其他导入语法

你可以通过 import {old as new} 这样的格式给导入项重命名:

image-20260104233818044

你也可以把上面的语法组合到一条 import 语句中:

image-20260104233832662

你还可以使用 * as name,把所有导出的对象放进一个命名空间对象中:

image-20260104233844829

你也可以使用 import "./file" 导入一个文件,同时不把任何变量引入当前模块:

// @filename: app.ts
import "./maths.js";
 
console.log("3.14");Try

在这种情况下,import 本身不会引入任何值。不过,maths.ts 中的所有代码仍然会被执行,这可能触发影响其他对象的副作用。

TypeScript 特有的 ES 模块语法

类型也可以使用与 JavaScript 值相同的语法进行导出和导入:

// @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.js";
type Animals = Cat | Dog;Try

TypeScript 在 import 语法上扩展了两个“专门导入类型”的概念:

import type

它是一种只能导入类型的导入语句:

image-20260104233857694

内联 type 导入

从 TypeScript 4.5 开始,也允许给单个导入项加上 type 前缀,以表明该导入引用是一个类型:

// @filename: app.ts
import { createCatName, type Cat, type Dog } from "./animal.js";
 
export type Animals = Cat | Dog;
const name = createCatName();Try

结合起来使用时,这些语法能让 Babel、swc、esbuild 这类非 TypeScript 转译器知道哪些导入可以被安全移除。

具有 CommonJS 行为的 ES 模块语法

TypeScript 还提供了一种与 CommonJS 和 AMD 中 require 直接对应的 ES 模块语法。使用 ES Module 的导入在大多数情况下与这些环境中的 require 等价,但这种语法可以确保你的 TypeScript 文件与最终的 CommonJS 输出保持一一对应:

import fs = require("fs");
const code = fs.readFileSync("hello.ts", "utf8");Try

你可以在模块参考页面中了解更多这种语法的细节。

CommonJS 语法

CommonJS 是 npm 上大多数模块发布时所使用的格式。即使你正在使用上面的 ES Modules 语法编写代码,简单了解一下 CommonJS 的工作方式,仍然会让你在调试时更轻松。

导出

标识符是通过给名为 module 的全局对象上的 exports 属性赋值来导出的。

function absolute(num: number) {
  if (num < 0) return num * -1;
  return num;
}
 
module.exports = {
  pi: 3.14,
  squareTwo: 1.41,
  phi: 1.61,
  absolute,
};Try

然后,这些文件就可以通过 require 语句导入:

image-20260104233913280

或者你也可以利用 JavaScript 的解构特性,把写法再简化一点:

image-20260104233917474

CommonJS 与 ES Modules 的互操作

在“默认导入”和“模块命名空间对象导入”的区分上,CommonJS 与 ES Modules 的特性并不完全匹配。TypeScript 提供了一个编译选项 esModuleInterop,用来减少这两套约束之间的摩擦。

TypeScript 的模块解析选项

模块解析,是指从 importrequire 语句中取出一个字符串,并确定这个字符串对应哪个文件的过程。

TypeScript 提供了两种解析策略:Classic 和 Node。当编译器选项 module 不是 commonjs 时,默认使用 Classic,它主要是为了向后兼容而保留的。Node 策略则会模拟 Node.js 在 CommonJS 模式下的行为,并额外检查 .ts.d.ts 文件。

有很多 TSConfig 选项都会影响 TypeScript 中的模块策略,例如:moduleResolutionbaseUrlpathsrootDirs

如果你想了解这些策略是如何工作的完整细节,可以参考 Module Resolution 页面。

TypeScript 的模块输出选项

有两个选项会影响生成的 JavaScript 输出:

  • target,用于决定哪些 JS 特性会被降级(转换为可在旧版 JavaScript 运行时中执行),哪些会被保留
  • module,用于决定模块之间交互时采用什么样的代码形式

选择什么 target,取决于你希望 TypeScript 代码运行在哪种 JavaScript 环境中。这个环境可能是你需要支持的最旧浏览器,也可能是你预期运行的最低 Node.js 版本,还可能来自某些运行时自身的特殊限制,例如 Electron。

模块之间的所有通信都通过模块加载器完成,而编译选项 module 就决定了使用哪一种模块加载器。在运行时,模块加载器负责在执行某个模块之前先定位并执行它的所有依赖。

例如,下面是一个使用 ES Modules 语法的 TypeScript 文件,它展示了 module 的几种不同选项:

import { valueOfPi } from "./constants.js";
 
export const twoPi = valueOfPi * 2;Try
ES2020
import { valueOfPi } from "./constants.js";
export const twoPi = valueOfPi * 2;
 Try
CommonJS
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.twoPi = void 0;
const constants_js_1 = require("./constants.js");
exports.twoPi = constants_js_1.valueOfPi * 2;
 Try
UMD
(function (factory) {
    if (typeof module === "object" && typeof module.exports === "object") {
        var v = factory(require, exports);
        if (v !== undefined) module.exports = v;
    }
    else if (typeof define === "function" && define.amd) {
        define(["require", "exports", "./constants.js"], factory);
    }
})(function (require, exports) {
    "use strict";
    Object.defineProperty(exports, "__esModule", { value: true });
    exports.twoPi = void 0;
    const constants_js_1 = require("./constants.js");
    exports.twoPi = constants_js_1.valueOfPi * 2;
});
 Try

注意,ES2020 的输出实际上和最初的 index.ts 基本相同。

你可以在 TSConfig 中 module 的参考页中查看所有可用选项,以及它们各自产生的 JavaScript 代码形式。

TypeScript 命名空间

TypeScript 有一种早于 ES Modules 标准的自有模块格式,叫做 namespaces。这种语法在创建复杂的声明文件时有很多实用特性,并且至今仍在 DefinitelyTyped 中被积极使用。虽然它并没有被弃用,但命名空间中的大多数能力在 ES Modules 中也都已经存在,因此我们建议你优先使用 ES Modules,以保持与 JavaScript 的发展方向一致。你可以在命名空间参考页面中了解更多内容。

评论

评论加载中……