TypeScript 3.9

推断与 Promise.all 的改进

近期版本的 TypeScript(大约 3.7)更新了 Promise.all 和 Promise.race 等函数的声明。遗憾的是,这引入了一些回归,尤其是在混入 null 或 undefined 值时。

ts
interface Lion {
roar(): void;
}
interface Seal {
singKissFromARose(): void;
}
async function visitZoo(
lionExhibit: Promise<Lion>,
sealExhibit: Promise<Seal | undefined>
) {
let [lion, seal] = await Promise.all([lionExhibit, sealExhibit]);
lion.roar(); // uh oh
// ~~~~
// Object is possibly 'undefined'.
}

这种行为很奇怪!sealExhibit 包含 undefined 这一事实,不知怎么就污染了 lion 的类型,使其包含了 undefined。

感谢这个拉取请求(作者是 Jack Bates),这一问题已通过 TypeScript 3.9 中推断过程的改进得到修复。上面的代码不再报错。如果你因为 Promise 相关的问题而停留在较旧的 TypeScript 版本,我们鼓励你试一试 3.9!

那 awaited 类型呢?

如果你一直关注我们的问题跟踪器和设计会议纪要,可能已经注意到围绕一个名为 awaited 的新类型运算符的一些工作。这个类型运算符的目标是准确模拟 JavaScript 中 Promise 解包的工作方式。

我们最初预计在 TypeScript 3.9 中交付 awaited,但在用现有代码库运行早期的 TypeScript 构建后,我们意识到该特性还需要更多设计工作,才能顺利地推出给所有人。因此,我们决定将该特性从主分支中撤出,直到我们更有把握。我们会继续对该特性进行更多试验,但不会在此次发行中交付它。

速度改进

TypeScript 3.9 带来了许多新的速度改进。在观察到 material-ui 和 styled-components 等包的编辑/编译速度极差之后,我们团队一直专注于性能。我们在这方面深入探索,提交了一系列不同的拉取请求,优化了涉及大型联合类型、交叉类型、条件类型和映射类型的某些病态情况。

这些拉取请求每一个都能在某些代码库上带来约 5-10% 的编译时间减少。总体而言,我们相信 material-ui 的编译时间已减少约 40%!

我们还对编辑器场景中的文件重命名功能做了一些更改。我们从 Visual Studio Code 团队那里得知,重命名文件时,仅仅找出哪些导入语句需要更新就可能要花 5 到 10 秒。TypeScript 3.9 通过更改编译器和语言服务缓存文件查找的内部实现解决了这个问题。

虽然仍有改进空间,但我们希望这些工作能为每个人带来更迅捷的体验!

// @ts-expect-error 注释

假设我们用 TypeScript 编写一个库,并把某个名为 doStuff 的函数作为公共 API 的一部分导出。该函数的类型声明它接收两个 string,以便其他 TypeScript 用户能获得类型检查错误;但它还会执行运行时检查(也许只在开发构建中),以便给 JavaScript 用户一个有用的错误。

ts
function doStuff(abc: string, xyz: string) {
assert(typeof abc === "string");
assert(typeof xyz === "string");
// do some stuff
}

因此,TypeScript 用户误用该函数时会看到有用的红色波浪线和错误消息,而 JavaScript 用户会得到断言错误。我们想测试这一行为,因此会编写一个单元测试。

ts
expect(() => {
doStuff(123, 456);
}).toThrow();

不幸的是,如果我们的测试是用 TypeScript 编写的,TypeScript 会报错!

ts
doStuff(123, 456);
// ~~~
// error: Type 'number' is not assignable to type 'string'.

这就是 TypeScript 3.9 带来一项新特性:// @ts-expect-error 注释的原因。当某一行前面有 // @ts-expect-error 注释时,TypeScript 会抑制报告该行的错误;但如果没有错误,TypeScript 会报告不需要 // @ts-expect-error。

举个简单的例子,下面的代码没有问题

ts
// @ts-expect-error
console.log(47 * "octopus");

而下面的代码

ts
// @ts-expect-error
console.log(1 + 1);

会导致错误

Unused '@ts-expect-error' directive.

我们要特别感谢实现该特性的贡献者 Josh Goldberg。更多信息可以查看ts-expect-error 拉取请求。

用 ts-ignore 还是 ts-expect-error?

在某些方面,// @ts-expect-error 可以充当抑制注释,类似于 // @ts-ignore。区别在于,如果下一行没有错误,// @ts-ignore 什么也不会做。

你可能想把现有的 // @ts-ignore 注释换成 // @ts-expect-error,也可能想知道将来编写代码时该用哪个。这完全取决于你和你的团队,但对于某些情况该选哪个,我们有一些建议。

如果符合以下情况,请选择 ts-expect-error:

  • 你正在编写测试代码,并且确实希望类型系统对某个操作报错
  • 你预计修复很快就会到来,只是需要一个临时的变通方案
  • 你所在的项目规模适中,且团队积极主动,希望在受影响的代码重新有效后尽快移除抑制注释

如果符合以下情况,请选择 ts-ignore:

  • 你的项目规模较大,且代码中出现了新的错误,但没有明确的负责人
  • 你正在两个不同版本的 TypeScript 之间进行升级,某行代码在一个版本中报错,而在另一个版本中不报错。
  • 你确实没有时间决定这些选项中哪个更好。

条件表达式中的未调用函数检查

在 TypeScript 3.7 中,我们引入了未调用函数检查,在你忘记调用函数时报告错误。

ts
function hasImportantPermissions(): boolean {
// ...
}
// Oops!
if (hasImportantPermissions) {
// ~~~~~~~~~~~~~~~~~~~~~~~
// This condition will always return true since the function is always defined.
// Did you mean to call it instead?
deleteAllTheImportantFiles();
}

然而,这个错误只适用于 if 语句中的条件。感谢这个拉取请求(来自 Alexander Tarasyuk),该特性现在也支持三元条件表达式(即 cond ? trueExpr : falseExpr 语法)。

ts
declare function listFilesOfDirectory(dirPath: string): string[];
declare function isDirectory(): boolean;
function getAllFiles(startFileName: string) {
const result: string[] = [];
traverse(startFileName);
return result;
function traverse(currentPath: string) {
return isDirectory
? // ~~~~~~~~~~~
// This condition will always return true
// since the function is always defined.
// Did you mean to call it instead?
listFilesOfDirectory(currentPath).forEach(traverse)
: result.push(currentPath);
}
}

https://github.com/microsoft/TypeScript/issues/36048

编辑器改进

TypeScript 编译器不仅为大多数主流编辑器中的 TypeScript 编辑体验提供支持,还为 Visual Studio 系列编辑器等中的 JavaScript 体验提供支持。在你的编辑器中使用新的 TypeScript/JavaScript 功能会因编辑器而异,但

JavaScript 中的 CommonJS 自动导入

一项很棒的新改进针对的是使用 CommonJS 模块的 JavaScript 文件中的自动导入。

在较早的版本中,TypeScript 总是假设无论你的文件是什么样,你都想要 ECMAScript 风格的导入,如下所示

js
import * as fs from "fs";

然而,在编写 JavaScript 文件时,并非所有人都面向 ECMAScript 风格的模块。许多用户仍然像下面这样使用 CommonJS 风格的 require(...) 导入

js
const fs = require("fs");

TypeScript 现在会自动检测你正在使用的导入类型,以保持文件风格干净且一致。

有关此更改的更多细节,请参阅相应的拉取请求。

代码操作会保留换行

TypeScript 的重构和快速修复往往没能很好地保留换行。举个非常基础的例子,看看下面的代码。

ts
const maxValue = 100;
/*start*/
for (let i = 0; i <= maxValue; i++) {
// First get the squared value.
let square = i ** 2;
// Now print the squared value.
console.log(square);
}
/*end*/

如果我们在编辑器中选中从 /*start*/ 到 /*end*/ 的范围,并将其提取为新函数,最终得到的代码会像下面这样。

ts
const maxValue = 100;
printSquares();
function printSquares() {
for (let i = 0; i <= maxValue; i++) {
// First get the squared value.
let square = i ** 2;
// Now print the squared value.
console.log(square);
}
}

在较旧版本的 TypeScript 中把 for 循环提取为函数。换行不会被保留。

这并不理想——我们的 for 循环中每条语句之间都有一行空行,但重构把它去掉了!TypeScript 3.9 会做更多工作来保留我们写下的内容。

ts
const maxValue = 100;
printSquares();
function printSquares() {
for (let i = 0; i <= maxValue; i++) {
// First get the squared value.
let square = i ** 2;
// Now print the squared value.
console.log(square);
}
}

在 TypeScript 3.9 中把 for 循环提取为函数。换行会被保留。

你可以在这个拉取请求中了解更多实现细节

缺失返回表达式的快速修复

有时我们会忘记返回函数中最后一条语句的值,尤其是在给箭头函数添加花括号时。

ts
// before
let f1 = () => 42;
// oops - not the same!
let f2 = () => {
42;
};

多亏了一个拉取请求(来自社区成员 Wenlu Wang),TypeScript 现在可以提供快速修复:为看起来极像对象字面量的箭头函数体添加缺失的 return 语句、移除花括号或添加括号。

TypeScript 通过添加 return 语句或移除花括号来修复未返回表达式的错误。

对“解决方案式”tsconfig.json 文件的支持

编辑器需要判断某个文件属于哪个配置文件,以便应用合适的选项,并判断当前“项目”中还包含哪些其他文件。默认情况下,由 TypeScript 语言服务器驱动的编辑器会逐级向上查找每个父目录中的 tsconfig.json 来完成这件事。

稍显棘手的一种情况是:某个 tsconfig.json 的存在仅仅是为了引用其他 tsconfig.json 文件。

// tsconfig.json
{
"": [],
{ "path": "./tsconfig.shared.json" },
{ "path": "./tsconfig.frontend.json" },
{ "path": "./tsconfig.backend.json" }
]
}

这种除了管理其他项目文件之外别无他用的文件,在某些环境中通常被称为“解决方案(solution)”。在这里,这些 tsconfig.*.json 文件都不会被服务器识别,但我们非常希望语言服务器能明白:当前的 .ts 文件很可能属于这个根 tsconfig.json 中所提到的某个项目。

TypeScript 3.9 为这种配置的编辑场景增加了支持。更多细节,请查看添加此功能的拉取请求。

破坏性变更

可选链与非空断言中的解析差异

TypeScript 最近实现了可选链操作符,但我们收到用户反馈称:可选链(?.)与非空断言操作符(!)一起使用时的行为极其违反直觉。

具体来说,在以前的版本中,代码

ts
foo?.bar!.baz;

被解释为等同于以下 JavaScript。

js
(foo?.bar).baz;

在上面的代码中,括号阻止了可选链的“短路”行为,因此如果 foo 是 undefined,访问 baz 就会导致运行时错误。

指出这一行为的 Babel 团队,以及向我们提供反馈的大多数用户,都认为这种行为是错误的。我们也这么认为!我们听到最多的说法是,! 操作符应该直接“消失”,因为其意图是从 bar 的类型中移除 null 和 undefined。

换句话说,大多数人觉得原来的代码片段应该被解释为

js
foo?.bar.baz;

当 foo 为 undefined 时,它的求值结果就是 undefined。

这是一个破坏性变更,但我们相信大多数代码在编写时就考虑到了新的解释方式。希望恢复旧行为的用户,可以在 ! 操作符的左侧显式添加括号。

ts
foo?.bar!.baz;

} 和 > 现在是不合法的 JSX 文本字符

JSX 规范禁止在文本位置使用 } 和 > 字符。TypeScript 和 Babel 都决定执行这一规则,以更符合规范。插入这些字符的新做法是使用 HTML 转义码(例如 <span> 2 &gt 1 </span>),或者插入一个带字符串字面量的表达式(例如 <span> 2 {">"} 1 </span>)。

幸运的是,多亏了为执行这一规则而提交的拉取请求(来自 Brad Zacher),你会看到类似下面的错误消息

Unexpected token. Did you mean `{'>'}` or `>`?
Unexpected token. Did you mean `{'}'}` or `}`?

例如:

tsx
let directions = <span>Navigate to: Menu Bar > Tools > Options</span>;
// ~ ~
// Unexpected token. Did you mean `{'>'}` or `>`?

该错误消息还附带了一个方便的快速修复,并且多亏了 Alexander Tarasyuk,如果你有大量错误,可以批量应用这些更改。

对交叉类型与可选属性的更严格检查

一般来说,只要 A 或 B 之一可赋值给 C,像 A & B 这样的交叉类型就可赋值给 C;然而,有时这在可选属性上会出问题。例如,看看下面这段:

ts
interface A {
a: number; // notice this is 'number'
}
interface B {
b: string;
}
interface C {
a?: boolean; // notice this is 'boolean'
b: string;
}
declare let x: A & B;
declare let y: C;
y = x;

在以前的 TypeScript 版本中,这是允许的,因为虽然 A 与 C 完全不兼容,但 B确实与 C 兼容。

在 TypeScript 3.9 中,只要交叉类型中的每个类型都是具体的对象类型,类型系统就会一次性考虑所有属性。因此,TypeScript 会发现 A & B 的 a 属性与 C 的对应属性不兼容:

Type 'A & B' is not assignable to type 'C'.
Types of property 'a' are incompatible.
Type 'number' is not assignable to type 'boolean | undefined'.

有关此更改的更多信息,请参阅相应的拉取请求。

通过判别属性归约交叉类型

在某些情况下,你可能会得到一些描述根本不存在的值的类型。例如

ts
declare function smushObjects<T, U>(x: T, y: U): T & U;
interface Circle {
kind: "circle";
radius: number;
}
interface Square {
kind: "square";
sideLength: number;
}
declare let x: Circle;
declare let y: Square;
let z = smushObjects(x, y);
console.log(z.kind);

这段代码略显奇怪,因为实际上无法创建 Circle 和 Square 的交叉类型——它们有两个不兼容的 kind 字段。在以前版本的 TypeScript 中,这段代码是允许的,而 kind 本身的类型是 never,因为 "circle" & "square" 描述的是一组永远不可能存在的值,即 never。

在 TypeScript 3.9 中,类型系统在这里更激进——它注意到由于 Circle 和 Square 的 kind 属性,它们不可能交叉。因此,它不再把 z.kind 的类型收窄为 never,而是把 z 本身的类型(Circle & Square)收窄为 never。这意味着上面的代码现在会报如下错误:

Property 'kind' does not exist on type 'never'.

我们观察到的大多数破坏性变更似乎都对应着稍有不正确的类型声明。更多详情请参阅最初的 pull request。

Getter/Setter 不再是可枚举的

在旧版本的 TypeScript 中,类中的 get 和 set 访问器生成的方式使它们可枚举;然而这不符合 ECMAScript 规范,规范要求它们必须不可枚举。因此,面向 ES5 和 ES2015 的 TypeScript 代码在行为上可能有所不同。

得益于一个 pull request(来自 GitHub 用户 pathurs),TypeScript 3.9 在这方面与 ECMAScript 更加一致。

扩展 any 的类型参数不再被视为 any

在以前版本的 TypeScript 中,约束为 any 的类型参数可以被当作 any。

ts
function foo<T extends any>(arg: T) {
arg.spfjgerijghoied; // no error!
}

这是一个疏漏,因此 TypeScript 3.9 采取更保守的方式,对这些可疑操作报错。

ts
function foo<T extends any>(arg: T) {
arg.spfjgerijghoied;
// ~~~~~~~~~~~~~~~
// Property 'spfjgerijghoied' does not exist on type 'T'.
}

export * 始终被保留

在以前的 TypeScript 版本中,如果 foo 没有导出任何值,那么 export * from "foo" 这样的声明会从我们的 JavaScript 输出中删除。这种输出方式有问题,因为它是由类型驱动的,Babel 无法模拟。TypeScript 3.9 将始终输出这些 export * 声明。在实践中,我们预计这不会破坏太多现有代码。

更多 lib.dom.d.ts 改进

我们正继续把更多 TypeScript 内置的 .d.ts 库(lib.d.ts 及其家族)改为直接根据 DOM 规范的 Web IDL 文件生成。因此,一些与媒体访问相关的厂商特定类型已被移除。

将此文件添加到项目的环境 *.d.ts 中即可将它们恢复:

ts
interface AudioTrackList {
[Symbol.iterator](): IterableIterator<AudioTrack>;
}
interface HTMLVideoElement {
readonly audioTracks: AudioTrackList
msFrameStep(forward: boolean): void;
msInsertVideoEffect(activatableClassId: string, effectRequired: boolean, config?: any): void;
msSetVideoRectangle(left: number, top: number, right: number, bottom: number): void;
webkitEnterFullScreen(): void;
webkitEnterFullscreen(): void;
webkitExitFullScreen(): void;
webkitExitFullscreen(): void;
msHorizontalMirror: boolean;
readonly msIsLayoutOptimalForPlayback: boolean;
readonly msIsStereo3D: boolean;
msStereo3DPackingMode: string;
msStereo3DRenderMode: string;
msZoom: boolean;
onMSVideoFormatChanged: ((this: HTMLVideoElement, ev: Event) => any) | null;
onMSVideoFrameStepCompleted: ((this: HTMLVideoElement, ev: Event) => any) | null;
onMSVideoOptimalLayoutChanged: ((this: HTMLVideoElement, ev: Event) => any) | null;
webkitDisplayingFullscreen: boolean;
webkitSupportsFullscreen: boolean;
}
interface MediaError {
readonly msExtendedCode: number;
readonly MS_MEDIA_ERR_ENCRYPTED: number;
}

TypeScript 文档是一个开源项目。欢迎发送 Pull Request 帮助我们改进这些页面 ❤

最后更新:2026 年 10 月 23 日