TypeScript 4.7

Node.js 中的 ECMAScript 模块支持

过去几年里,Node.js 一直在努力支持 ECMAScript 模块(ESM)。这是一个非常困难的功能,因为 Node.js 生态建立在另一种名为 CommonJS(CJS)的模块系统之上。两者之间的互操作带来了巨大挑战,需要兼顾许多新特性;不过,Node.js 对 ESM 的支持在很大程度上已在 Node.js 12 及更高版本中实现。大约在 TypeScript 4.5 时,我们推出了仅限每日构建的 Node.js ESM 支持,以获取用户反馈,并让库作者为更广泛的支持做好准备。

TypeScript 4.7 通过两个新的 module 设置添加了此功能:node16 和 nodenext。

jsonc
{
"compilerOptions": {
"module": "node16",
}
}

这些新模式带来了一些高层次特性,我们将在下面进行探讨。

package.json 中的 type 与新扩展名

Node.js 支持 package.json 中的一项新设置,名为 type。"type" 可以设为 "module" 或 "commonjs"。

jsonc
{
"name": "my-package",
"type": "module",
"//": "...",
"dependencies": {
}
}

此设置控制 .js 和 .d.ts 文件被解释为 ES 模块还是 CommonJS 模块,未设置时默认为 CommonJS。当一个文件被视为 ES 模块时,与 CommonJS 相比会有一些不同的规则生效:

  • 可以使用 import/export 语句。
  • 可以使用顶层 await
  • 相对导入路径需要完整扩展名(必须写 import "./foo.js" 而不是 import "./foo")。
  • 导入的解析方式可能与 node_modules 中的依赖不同。
  • 某些类似全局的值(如 require 和 module)不能直接使用。
  • CommonJS 模块会按照某些特殊规则被导入。

我们稍后会回头讨论其中一些内容。

为了让 TypeScript 在这个系统中也以相应方式工作,.ts 和 .tsx 文件现在也遵循同样的方式。当 TypeScript 找到 .ts、.tsx、.js 或 .jsx 文件时,它会向上查找 package.json,以判断该文件是否为 ES 模块,并用它来确定:

  • 如何查找该文件导入的其他模块
  • 以及在生成输出时如何转换该文件

当 .ts 文件作为 ES 模块编译时,ECMAScript 的 import/export 语句会在 .js 输出中保持原样;当它作为 CommonJS 模块编译时,将产生与目前 --module commonjs 下相同的输出。

这也意味着,在作为 ES 模块的 .ts 文件和作为 CJS 模块的 .ts 文件之间,路径解析方式不同。例如,假设你目前有以下代码:

ts
// ./foo.ts
export function helper() {
// ...
}
// ./bar.ts
import { helper } from "./foo"; // only works in CJS
helper();

这段代码在 CommonJS 模块中可以工作,但在 ES 模块中会失败,因为相对导入路径需要使用扩展名。因此,必须改写为使用 foo.ts 输出的扩展名——所以 bar.ts 将不得不从 ./foo.js 导入。

ts
// ./bar.ts
import { helper } from "./foo.js"; // works in ESM & CJS
helper();

起初这可能感觉有点麻烦,但像自动导入和路径补全这样的 TypeScript 工具通常会自动为你完成。

另外要提到的一点是,这也适用于 .d.ts 文件。当 TypeScript 在某个包中找到 .d.ts 文件时,会根据其所在的包来解释它。

新的文件扩展名

package.json 中的 type 字段很好,因为它让我们可以继续使用 .ts 和 .js 文件扩展名,这很方便;不过,你偶尔需要编写与 type 指定的类型不同的文件。你也可能只是更愿意始终显式指定。

Node.js 支持两个扩展名来帮助解决这个问题:.mjs 和 .cjs。.mjs 文件始终是 ES 模块,.cjs 文件始终是 CommonJS 模块,而且无法覆盖这一规则。

相应地,TypeScript 支持两个新的源文件扩展名:.mts 和 .cts。当 TypeScript 将它们输出为 JavaScript 文件时,会分别输出为 .mjs 和 .cjs。

此外,TypeScript 还支持两个新的声明文件扩展名:.d.mts 和 .d.cts。当 TypeScript 为 .mts 和 .cts 生成声明文件时,它们对应的扩展名将是 .d.mts 和 .d.cts。

使用这些扩展名完全是可选的,但即使你选择不在主要工作流中使用它们,它们也常常很有用。

CommonJS 互操作性

Node.js 允许 ES 模块导入 CommonJS 模块,就像它们是带有默认导出的 ES 模块一样。

ts
// ./foo.cts
export function helper() {
console.log("hello world!");
}
// ./bar.mts
import foo from "./foo.cjs";
// prints "hello world!"
foo.helper();

在某些情况下,Node.js 还会从 CommonJS 模块合成具名导出,这会更方便。在这些情况下,ES 模块可以使用“命名空间风格”的导入(即 import * as foo from "..."),或具名导入(即 import { helper } from "...")。

ts
// ./foo.cts
export function helper() {
console.log("hello world!");
}
// ./bar.mts
import { helper } from "./foo.cjs";
// prints "hello world!"
helper();

TypeScript 并不总能知道这些具名导入是否会被合成,但在从确定是 CommonJS 模块的文件导入时,TypeScript 会偏向宽松处理,并采用一些启发式规则。

关于互操作,有一个 TypeScript 特有的注意事项,涉及以下语法:

ts
import foo = require("foo");

在 CommonJS 模块中,这归根结底就是一次 require() 调用;而在 ES 模块中,它会导入 createRequire 来达到相同效果。这会使代码在浏览器等运行时上可移植性降低(这些运行时不支持 require()),但通常对互操作很有用。相应地,你可以用以下语法来编写上面的示例:

ts
// ./foo.cts
export function helper() {
console.log("hello world!");
}
// ./bar.mts
import foo = require("./foo.cjs");
foo.helper()

最后,值得注意的是,从 CJS 模块导入 ESM 文件的唯一方式是使用动态 import() 调用。这可能会带来一些挑战,但这就是如今 Node.js 的行为。

你可以在这里进一步了解 Node.js 中 ESM/CommonJS 的互操作。

package.json 的导出、导入与自引用

Node.js 支持package.json 中一个用于定义入口点的新字段 "exports"。该字段是 package.json 中定义 "main" 的更强大替代方案,可以控制包的哪些部分暴露给使用者。

下面是一个同时支持 CommonJS 和 ESM 独立入口点的 package.json:

jsonc
// package.json
{
"name": "my-package",
"type": "module",
"exports": {
".": {
// Entry-point for `import "my-package"` in ESM
"import": "./esm/index.js",
// Entry-point for `require("my-package") in CJS
"require": "./commonjs/index.cjs",
},
},
// CJS fall-back for older versions of Node.js
"main": "./commonjs/index.cjs",
}

这个特性内容很多,你可以在 Node.js 文档中了解更多。这里我们将重点讨论 TypeScript 如何支持它。

在 TypeScript 最初的 Node 支持中,它会查找 "main" 字段,然后查找与该入口对应的声明文件。例如,如果 "main" 指向 ./lib/index.js,TypeScript 会查找名为 ./lib/index.d.ts 的文件。包作者可以通过指定一个名为 "types" 的单独字段(例如 "types": "./types/index.d.ts")来覆盖这一行为。

新的支持与导入条件的工作方式类似。默认情况下,TypeScript 会套用与导入条件相同的规则——如果你从 ES 模块中编写 import,它会查找 import 字段;而从 CommonJS 模块导入时,它会查看 require 字段。如果找到了这些字段,它就会查找对应的声明文件。如果你需要把类型声明指向其他位置,可以添加 "types" 导入条件。

jsonc
// package.json
{
"name": "my-package",
"type": "module",
"exports": {
".": {
// Entry-point for `import "my-package"` in ESM
"import": {
// Where TypeScript will look.
"types": "./types/esm/index.d.ts",
// Where Node.js will look.
"default": "./esm/index.js"
},
// Entry-point for `require("my-package") in CJS
"require": {
// Where TypeScript will look.
"types": "./types/commonjs/index.d.cts",
// Where Node.js will look.
"default": "./commonjs/index.cjs"
},
}
},
// Fall-back for older versions of TypeScript
"types": "./types/index.d.ts",
// CJS fall-back for older versions of Node.js
"main": "./commonjs/index.cjs"
}

"types" 条件应始终位于 "exports" 的最前面。

需要注意的是,CommonJS 入口点和 ES 模块入口点各自都需要自己的声明文件,即使两者的内容完全相同。每个声明文件都会根据其文件扩展名以及 package.json 中的 "type" 字段,被解释为 CommonJS 模块或 ES 模块;而只有这样检测出的模块类型与 Node 为对应 JavaScript 文件检测到的模块类型一致时,类型检查才是正确的。如果试图用单个 .d.ts 文件同时为 ES 模块入口点和 CommonJS 入口点提供类型,TypeScript 会认为其中只有一个入口点存在,从而给该包的使用者带来编译器错误。

TypeScript 还以类似方式支持package.json 中的 "imports" 字段,其做法是在对应文件旁边查找声明文件;此外还支持包自引用自身。这些特性通常配置起来并不繁琐,但都是受支持的。

期待你的反馈!

在我们继续推进 TypeScript 4.7 的过程中,预计这项功能还会补充更多文档并进一步打磨。支持这些新特性是一项颇有雄心的工程,正因如此,我们希望尽早获得相关反馈!请试用一下,并告诉我们它在你那里的表现。

更多信息,你可以在这里查看实现该功能的 PR。

控制模块检测

JavaScript 引入模块时带来的一个问题是,已有的“脚本”代码与新出现的模块代码之间存在歧义。模块中的 JavaScript 代码运行方式略有不同,作用域规则也不一样,因此工具必须决定每个文件以何种方式运行。例如,Node.js 要求模块入口点写成 .mjs,或者在附近有一个 package.json,其中含有 "type": "module"。只要文件中出现任何 import 或 export 语句,TypeScript 就会把它当作模块;否则,它会假定 .ts 或 .js 文件是作用于全局作用域的脚本文件。

这与 Node.js 的行为并不完全一致——在 Node.js 中,package.json 可以改变文件的格式;这也与 --jsx 设置为 react-jsx 时的行为不一致——此时任何 JSX 文件都会隐式导入某个 JSX 工厂。它同样不符合现代的预期:如今大多数新的 TypeScript 代码在编写时都考虑到了模块。

正因如此,TypeScript 4.7 引入了名为 moduleDetection 的新选项。moduleDetection 可以取 3 个值:"auto"(默认值)、"legacy"(与 4.6 及更早版本相同的行为)和 "force"。

在 "auto" 模式下,TypeScript 不仅会查找 import 和 export 语句,还会检查以下情况:

  • 在 --module nodenext/--module node16 下运行时,package.json 中的 "type" 字段是否被设为 "module";以及
  • 在 --jsx react-jsx 下运行时,检查当前文件是否为 JSX 文件

如果你希望每个文件都被当作模块处理,"force" 设置可以确保所有非声明文件都被视为模块。无论 module、moduleResolution 和 jsx 如何配置,这一点都成立。

与此同时,"legacy" 选项只是回到旧行为:仅通过查找 import 和 export 语句来判断文件是否为模块。

你可以在这个 pull request 中进一步了解这项更改。

方括号元素访问的控制流分析

当索引键为字面量类型和 unique symbol 时,TypeScript 4.7 现在会收窄元素访问的类型。例如,看下面的代码:

ts
const key = Symbol();
const numberOrString = Math.random() < 0.5 ? 42 : "hello";
const obj = {
[key]: numberOrString,
};
if (typeof obj[key] === "string") {
let str = obj[key].toUpperCase();
}

以前,TypeScript 不会考虑 obj[key] 上的任何类型守卫,也无法知道 obj[key] 实际上是 string。相反,它会认为 obj[key] 仍然是 string | number,因此访问 toUpperCase() 会触发错误。

TypeScript 4.7 现在知道 obj[key] 是字符串。

这也意味着在 --strictPropertyInitialization 下,TypeScript 能够正确检查计算属性是否在构造函数体结束前完成初始化。

ts
// 'key' has type 'unique symbol'
const key = Symbol();
class C {
[key]: string;
constructor(str: string) {
// oops, forgot to set 'this[key]'
}
screamString() {
return this[key].toUpperCase();
}
}

在 TypeScript 4.7 下,--strictPropertyInitialization 会报错,告诉我们 [key] 属性在构造函数结束时尚未被明确赋值。

我们要感谢 Oleksandr Tarasiuk 提供了这项更改!

改进对象与方法中的函数推断

TypeScript 4.7 现在可以从对象和数组中的函数进行更细粒度的推断。这使得这些函数的类型能够像普通参数那样,始终以从左到右的方式流动。

ts
declare function f<T>(arg: {
produce: (n: string) => T,
consume: (x: T) => void }
): void;
// Works
f({
produce: () => "hello",
consume: x => x.toLowerCase()
});
// Works
f({
produce: (n: string) => n,
consume: x => x.toLowerCase(),
});
// Was an error, now works.
f({
produce: n => n,
consume: x => x.toLowerCase(),
});
// Was an error, now works.
f({
produce: function () { return "hello"; },
consume: x => x.toLowerCase(),
});
// Was an error, now works.
f({
produce() { return "hello" },
consume: x => x.toLowerCase(),
});

在上面的某些示例中推断失败,是因为要获知其中 produce 函数的类型,就会在为 T 找到合适的类型之前间接地请求 arg 的类型。TypeScript 现在会收集可能有助于推断 T 类型的函数,并以惰性方式从它们进行推断。

更多信息,你可以查看我们对推断过程所做的具体修改。

实例化表达式

有时函数可能比我们想要的更宽泛一些。例如,假设我们有一个 makeBox 函数。

ts
interface Box<T> {
value: T;
}
function makeBox<T>(value: T) {
return { value };
}

也许我们想创建一组更专用的函数,用来生成 Wrench 和 Hammer 的 Box。如今要做到这一点,我们必须把 makeBox 包装在其他函数中,或者为 makeBox 的别名使用显式类型。

ts
function makeHammerBox(hammer: Hammer) {
return makeBox(hammer);
}
// or...
const makeWrenchBox: (wrench: Wrench) => Box<Wrench> = makeBox;

这些做法可行,但包装对 makeBox 的调用有些浪费,而写出 makeWrenchBox 的完整签名也可能变得笨拙。理想情况下,我们应当能够表达:我们只是想给 makeBox 起个别名,同时替换其签名中的所有泛型。

TypeScript 4.7 恰好允许这样做!我们现在可以直接为函数和构造函数提供类型实参。

ts
const makeHammerBox = makeBox<Hammer>;
const makeWrenchBox = makeBox<Wrench>;

因此,借助这一特性,我们可以把 makeBox 特化为只接受更具体的类型,并拒绝其他任何类型。

ts
const makeStringBox = makeBox<string>;
// TypeScript correctly rejects this.
makeStringBox(42);

这一逻辑同样适用于 Array、Map 和 Set 等构造函数。

ts
// Has type `new () => Map<string, Error>`
const ErrorMap = Map<string, Error>;
// Has type `// Map<string, Error>`
const errorMap = new ErrorMap();

当为函数或构造函数提供类型实参时,它会生成一个新类型,保留所有具有兼容类型参数列表的签名,并用给定的类型实参替换相应的类型参数。其他签名都会被丢弃,因为 TypeScript 会假定它们并不打算被使用。

有关此功能的更多信息,请查看 pull request。

对 infer 类型变量的 extends 约束

条件类型算是进阶用户的特性。它们让我们能够对类型的形状进行匹配和推断,并据此做出判断。例如,我们可以编写一个条件类型:如果元组类型的第一个元素是类 string 的类型,就返回该元素类型。

ts
type FirstIfString<T> =
T extends [infer S, ...unknown[]]
? S extends string ? S : never
: never;
// string
type A = FirstIfString<[string, number, number]>;
// "hello"
type B = FirstIfString<["hello", number, number]>;
// "hello" | "world"
type C = FirstIfString<["hello" | "world", boolean]>;
// never
type D = FirstIfString<[boolean, number, string]>;

FirstIfString 会匹配任何至少含有一个元素的元组,并把第一个元素的类型捕获为 S。然后它检查 S 是否与 string 兼容,如果兼容就返回该类型。

注意,我们必须使用两个条件类型才能写出这个。我们也可以把 FirstIfString 写成下面这样:

ts
type FirstIfString<T> =
T extends [string, ...unknown[]]
// Grab the first type out of `T`
? T[0]
: never;

这样可行,但稍微更“手工”一些,也不够声明式。我们不是直接对类型做模式匹配并给第一个元素命名,而是必须用 T[0] 取出 T 的第 0 个元素。如果处理比元组更复杂的类型,这会困难得多,因此 infer 可以简化事情。

使用嵌套条件类型先推断出一个类型、再与该推断出的类型进行匹配,是相当常见的做法。为了避免第二层嵌套,TypeScript 4.7 现在允许你为任何 infer 类型添加约束。

ts
type FirstIfString<T> =
T extends [infer S extends string, ...unknown[]]
? S
: never;

这样,当 TypeScript 匹配 S 时,它也会确保 S 必须是 string。如果 S 不是 string,就会走 false 分支,在这些情况下即 never。

更多详情,你可以在 GitHub 上了解这项更改。

类型参数的可选变型标注

我们来看以下类型。

ts
interface Animal {
animalStuff: any;
}
interface Dog extends Animal {
dogStuff: any;
}
// ...
type Getter<T> = () => T;
type Setter<T> = (value: T) => void;

设想我们有两个不同的 Getter 实例。判断任意两个不同的 Getter 是否可以相互替代,完全取决于 T。对于 Getter<Dog> → Getter<Animal> 的赋值是否合法,我们需要检查 Dog → Animal 是否合法。由于 T 的每个类型都以相同的“方向”关联,我们说 Getter 类型在 T 上是协变的。另一方面,检查 Setter<Dog> → Setter<Animal> 是否合法,则涉及检查 Animal → Dog 是否合法。这种方向的“翻转”有点像数学中检查 −x < −y 等同于检查 y < x。当我们需要这样翻转方向来比较 T 时,我们说 Setter 在 T 上是逆变的。

在 TypeScript 4.7 中,我们现在可以显式指定类型参数的变型。

因此,如果我们想明确表示 Getter 在 T 上是协变的,现在可以给它加上 out 修饰符。

ts
type Getter<out T> = () => T;

同样,如果我们还想明确表示 Setter 在 T 上是逆变的,可以给它加上 in 修饰符。

ts
type Setter<in T> = (value: T) => void;

这里使用 out 和 in,是因为类型参数的变型取决于它用在输出位置还是输入位置。你不必从变型的角度思考,只需考虑 T 是否用在输出和输入位置。

也有同时使用 in 和 out 的情况。

ts
interface State<in out T> {
get: () => T;
set: (value: T) => void;
}

当 T 同时用在输出和输入位置时,它就变成不变的。两个不同的 State<T> 除非它们的 T 相同,否则不能互换。换句话说,State<Dog> 和 State<Animal> 不能相互替代。

严格来说,在纯结构化类型系统中,类型参数及其变型并不真正重要——你只需把类型代入每个类型参数的位置,并检查每个匹配的成员在结构上是否兼容。那么,如果 TypeScript 使用结构化类型系统,我们为什么还关心类型参数的变型?又为什么可能想要标注它们呢?

一个原因是,让读者一眼就能明确看出类型参数是如何使用的,这会很有帮助。对于复杂得多的类型,很难判断某个类型是用于读取、写入还是两者兼有。如果我们忘记说明该类型参数的使用方式,TypeScript 也会帮我们指出来。例如,如果我们忘记在 State 上同时指定 in 和 out,就会得到错误。

ts
interface State<out T> {
// ~~~~~
// error!
// Type 'State<sub-T>' is not assignable to type 'State<super-T>' as implied by variance annotation.
// Types of property 'set' are incompatible.
// Type '(value: sub-T) => void' is not assignable to type '(value: super-T) => void'.
// Types of parameters 'value' and 'value' are incompatible.
// Type 'super-T' is not assignable to type 'sub-T'.
get: () => T;
set: (value: T) => void;
}

另一个原因是精确性和速度!TypeScript 已经尝试推断类型参数的变型作为优化。这样做可以在合理时间内检查更大的结构化类型。提前计算变型让类型检查器可以跳过更深的比较,只比较类型实参,这比反复比较类型的完整结构快得多。但很多时候这种计算仍然相当昂贵,而且计算可能发现无法准确解决的循环,这意味着对于某个类型的变型没有明确答案。

ts
type Foo<T> = {
x: T;
f: Bar<T>;
}
type Bar<U> = (x: Baz<U[]>) => void;
type Baz<V> = {
value: Foo<V[]>;
}
declare let foo1: Foo<unknown>;
declare let foo2: Foo<string>;
foo1 = foo2; // Should be an error but isn't ❌
foo2 = foo1; // Error - correct ✅

提供显式标注可以在这些循环处加速类型检查,并提供更好的准确性。例如,在上面的示例中把 T 标记为不变,有助于阻止有问题的赋值。

diff
- type Foo<T> = {
+ type Foo<in out T> = {
x: T;
f: Bar<T>;
}

我们并不一定建议为每个类型参数都标注其变型;例如,有可能(但不建议)把变型设得比必要程度更严格,所以如果你把实际上只是协变、逆变甚至无关的参数标记为不变,TypeScript 也不会阻止你。因此,如果你确实选择添加显式变型标记,我们鼓励谨慎而精确地使用它们。

但如果你正在处理深度递归的类型,尤其是作为库作者,你可能会想用这些标注来为使用者带来好处。这些标注可以在准确性和类型检查速度两方面带来收益,甚至会影响他们的代码编辑体验。判断变型计算何时成为类型检查时间的瓶颈,可以通过实验来进行,并使用我们的 analyze-trace 工具等来确定。

关于此功能的更多详情,你可以阅读 pull request。

使用 moduleSuffixes 自定义解析

TypeScript 4.7 现在支持 moduleSuffixes 选项,用于自定义模块说明符的查找方式。

jsonc
{
"compilerOptions": {
"moduleSuffixes": [".ios", ".native", ""]
}
}

给定上面的配置,像下面这样的导入……

ts
import * as foo from "./foo";

会依次尝试查找相对文件 ./foo.ios.ts、./foo.native.ts,最后是 ./foo.ts。

对于 React Native 项目来说,此功能很有用:每个目标平台都可以使用单独的 tsconfig.json,并配置不同的 moduleSuffixes。

moduleSuffixes 选项得益于 Adam Foxman 的贡献!

resolution-mode

在 Node 的 ECMAScript 解析下,包含导入的文件处于哪种模式以及你使用的语法,决定了导入如何被解析;不过,从 ECMAScript 模块中引用 CommonJS 模块的类型(或反过来)会很有用。

TypeScript 现在允许 /// <reference types="..." /> 指令。

ts
/// <reference types="pkg" resolution-mode="require" />
// or
/// <reference types="pkg" resolution-mode="import" />

此外,在 TypeScript 的每日构建版本中,import type 可以指定导入断言来实现类似的效果。

ts
// Resolve `pkg` as if we were importing with a `require()`
import type { TypeFromRequire } from "pkg" assert {
"resolution-mode": "require"
};
// Resolve `pkg` as if we were importing with an `import`
import type { TypeFromImport } from "pkg" assert {
"resolution-mode": "import"
};
export interface MergedType extends TypeFromRequire, TypeFromImport {}

这些导入断言也可以用在 import() 类型上。

ts
export type TypeFromRequire =
import("pkg", { assert: { "resolution-mode": "require" } }).TypeFromRequire;
export type TypeFromImport =
import("pkg", { assert: { "resolution-mode": "import" } }).TypeFromImport;
export interface MergedType extends TypeFromRequire, TypeFromImport {}

import type 和 import() 语法仅在TypeScript 的每日构建中支持 resolution-mode。你很可能得到类似这样的错误

Resolution mode assertions are unstable. Use nightly TypeScript to silence this error. Try updating with 'npm install -D typescript@next'.

如果你确实在 TypeScript 的每日构建版本中使用了此功能,不妨在这个 issue 上提供反馈。

你可以在针对参考指令和针对类型导入断言的 pull request 中查看各自的更改。

转到源定义

TypeScript 4.7 支持一个新的实验性编辑器命令,名为转到源定义。它类似于转到定义,但绝不会返回声明文件内部的结果。相反,它会尝试找到对应的实现文件(如 .js 或 .ts 文件),并在其中查找定义——即使这些文件通常被 .d.ts 文件遮蔽。

当你需要查看从某个库导入的函数的实现,而不是它在 .d.ts 文件中的类型声明时,这个功能最有用。

在 yargs 包的使用处执行“转到源定义”命令后,编辑器会跳转到 yargs 中的 index.cjs 文件。

你可以在最新版本的 Visual Studio Code 中试用这个新命令。不过要注意,此功能仍处于预览阶段,并且存在一些已知限制。在某些情况下,TypeScript 会使用启发式方法猜测哪个 .js 文件对应于给定的定义结果,因此这些结果可能并不准确。Visual Studio Code 目前也还不会提示某个结果是否只是猜测,但这是我们正在合作改进的地方。

你可以在专门的反馈 issue 中留下关于此功能的反馈、了解已知限制,或获取更多信息。

按分组整理导入

TypeScript 为 JavaScript 和 TypeScript 都提供了整理导入编辑器功能。遗憾的是,它有时像个不太精细的工具,常常只是草率地对导入语句排序。

例如,如果你对下面的文件运行整理导入……

ts
// local code
import * as bbb from "./bbb";
import * as ccc from "./ccc";
import * as aaa from "./aaa";
// built-ins
import * as path from "path";
import * as child_process from "child_process"
import * as fs from "fs";
// some code...

你会得到类似下面的结果

ts
// local code
import * as child_process from "child_process";
import * as fs from "fs";
// built-ins
import * as path from "path";
import * as aaa from "./aaa";
import * as bbb from "./bbb";
import * as ccc from "./ccc";
// some code...

这……并不理想。导入确实按路径排序了,注释和换行也保留了,但结果并不是我们期望的样子。很多时候,如果我们以特定方式对导入分了组,就希望保持这种分组。

TypeScript 4.7 会以感知分组的方式执行整理导入。对上面的代码运行它,结果会更接近你的预期:

ts
// local code
import * as aaa from "./aaa";
import * as bbb from "./bbb";
import * as ccc from "./ccc";
// built-ins
import * as child_process from "child_process";
import * as fs from "fs";
import * as path from "path";
// some code...

我们要感谢 Minh Quy 提供了此功能。

对象方法片段补全

TypeScript 现在为对象字面量方法提供片段补全。在对象中补全成员时,TypeScript 会为方法名单独提供一个常规补全项,同时为完整的方法定义提供另一个补全项!

从对象补全完整的方法签名

更多详情,请参见实现此功能的 pull request。

破坏性变更

lib.d.ts 更新

尽管 TypeScript 努力避免重大破坏,但即便是内置库中的细微更改也可能引发问题。我们不认为 DOM 和 lib.d.ts 的更新会导致重大破坏,但可能会有一些小问题。

JSX 中更严格的展开检查

在 JSX 中编写 ...spread 时,TypeScript 现在会执行更严格的检查,确保给定的类型确实是对象。因此,类型为 unknown 和 never 的值(更少见的情况是裸的 null 和 undefined)不能再展开到 JSX 元素中。

因此,对于下面的示例:

tsx
import * as React from "react";
interface Props {
stuff?: string;
}
function MyComponent(props: unknown) {
return <div {...props} />;
}

你现在会收到类似下面的错误:

Spread types may only be created from object types.

这使得此行为与对象字面量中的展开更加一致。

更多详情,请在 GitHub 上查看这项更改。

对模板字符串表达式的更严格检查

在模板字符串中使用 symbol 值时,在 JavaScript 中会触发运行时错误。

js
let str = `hello ${Symbol()}`;
// TypeError: Cannot convert a Symbol value to a string

因此,TypeScript 也会报错;不过,TypeScript 现在还检查在模板字符串中使用的泛型值是否以某种方式被约束为 symbol。

ts
function logKey<S extends string | symbol>(key: S): S {
// Now an error.
console.log(`${key} is the key`);
return key;
}
function get<T, K extends keyof T>(obj: T, key: K) {
// Now an error.
console.log(`Grabbing property '${key}'.`);
return obj[key];
}

TypeScript 现在会报出以下错误:

Implicit conversion of a 'symbol' to a 'string' will fail at runtime. Consider wrapping this expression in 'String(...)'.

在某些情况下,你可以像错误消息建议的那样,把表达式包裹在 String 调用中,从而绕开这个问题。

ts
function logKey<S extends string | symbol>(key: S): S {
// No longer an error.
console.log(`${String(key)} is the key`);
return key;
}

在另一些情况下,这个错误过于吹毛求疵,而且你在使用 keyof 时可能根本不在意是否允许 symbol 键。此时,你可以改用 string & keyof ...:

ts
function get<T, K extends string & keyof T>(obj: T, key: K) {
// No longer an error.
console.log(`Grabbing property '${key}'.`);
return obj[key];
}

更多信息,你可以参见实现此功能的 pull request。

readFile 方法在 LanguageServiceHost 上不再是可选的

如果你要创建 LanguageService 实例,那么所提供的 LanguageServiceHost 就需要提供 readFile 方法。为了让新的 moduleDetection 编译器选项得到支持,这一更改是必要的。

你可以在此处进一步了解这项更改。

readonly 元组具有 readonly 的 length 属性

readonly 元组现在会将其 length 属性视为 readonly。对于固定长度的元组,这几乎从未被察觉到,但这是一个疏漏,在带有尾部可选元素和剩余元素类型的元组上可以被观察到。

因此,下面的代码现在会报错:

ts
function overwriteLength(tuple: readonly [string, string, string]) {
// Now errors.
tuple.length = 7;
}

你可以在此处进一步了解这项更改。

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

最后更新:2026 年 10 月 23 日