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.tsexport function helper() {// ...}// ./bar.tsimport { helper } from "./foo"; // only works in CJShelper();
这段代码在 CommonJS 模块中可以工作,但在 ES 模块中会失败,因为相对导入路径需要使用扩展名。因此,必须改写为使用 foo.ts 输出的扩展名——所以 bar.ts 将不得不从 ./foo.js 导入。
ts// ./bar.tsimport { helper } from "./foo.js"; // works in ESM & CJShelper();
起初这可能感觉有点麻烦,但像自动导入和路径补全这样的 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.ctsexport function helper() {console.log("hello world!");}// ./bar.mtsimport foo from "./foo.cjs";// prints "hello world!"foo.helper();
在某些情况下,Node.js 还会从 CommonJS 模块合成具名导出,这会更方便。在这些情况下,ES 模块可以使用“命名空间风格”的导入(即 import * as foo from "..."),或具名导入(即 import { helper } from "...")。
ts// ./foo.ctsexport function helper() {console.log("hello world!");}// ./bar.mtsimport { helper } from "./foo.cjs";// prints "hello world!"helper();
TypeScript 并不总能知道这些具名导入是否会被合成,但在从确定是 CommonJS 模块的文件导入时,TypeScript 会偏向宽松处理,并采用一些启发式规则。
关于互操作,有一个 TypeScript 特有的注意事项,涉及以下语法:
tsimport foo = require("foo");
在 CommonJS 模块中,这归根结底就是一次 require() 调用;而在 ES 模块中,它会导入 createRequire 来达到相同效果。这会使代码在浏览器等运行时上可移植性降低(这些运行时不支持 require()),但通常对互操作很有用。相应地,你可以用以下语法来编写上面的示例:
ts// ./foo.ctsexport function helper() {console.log("hello world!");}// ./bar.mtsimport 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 现在会收窄元素访问的类型。例如,看下面的代码:
tsconst 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 现在可以从对象和数组中的函数进行更细粒度的推断。这使得这些函数的类型能够像普通参数那样,始终以从左到右的方式流动。
tsdeclare function f<T>(arg: {produce: (n: string) => T,consume: (x: T) => void }): void;// Worksf({produce: () => "hello",consume: x => x.toLowerCase()});// Worksf({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 函数。
tsinterface Box<T> {value: T;}function makeBox<T>(value: T) {return { value };}
也许我们想创建一组更专用的函数,用来生成 Wrench 和 Hammer 的 Box。如今要做到这一点,我们必须把 makeBox 包装在其他函数中,或者为 makeBox 的别名使用显式类型。
tsfunction makeHammerBox(hammer: Hammer) {return makeBox(hammer);}// or...const makeWrenchBox: (wrench: Wrench) => Box<Wrench> = makeBox;
这些做法可行,但包装对 makeBox 的调用有些浪费,而写出 makeWrenchBox 的完整签名也可能变得笨拙。理想情况下,我们应当能够表达:我们只是想给 makeBox 起个别名,同时替换其签名中的所有泛型。
TypeScript 4.7 恰好允许这样做!我们现在可以直接为函数和构造函数提供类型实参。
tsconst makeHammerBox = makeBox<Hammer>;const makeWrenchBox = makeBox<Wrench>;
因此,借助这一特性,我们可以把 makeBox 特化为只接受更具体的类型,并拒绝其他任何类型。
tsconst 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 的类型,就返回该元素类型。
tstype FirstIfString<T> =T extends [infer S, ...unknown[]]? S extends string ? S : never: never;// stringtype A = FirstIfString<[string, number, number]>;// "hello"type B = FirstIfString<["hello", number, number]>;// "hello" | "world"type C = FirstIfString<["hello" | "world", boolean]>;// nevertype D = FirstIfString<[boolean, number, string]>;
FirstIfString 会匹配任何至少含有一个元素的元组,并把第一个元素的类型捕获为 S。然后它检查 S 是否与 string 兼容,如果兼容就返回该类型。
注意,我们必须使用两个条件类型才能写出这个。我们也可以把 FirstIfString 写成下面这样:
tstype 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 类型添加约束。
tstype FirstIfString<T> =T extends [infer S extends string, ...unknown[]]? S: never;
这样,当 TypeScript 匹配 S 时,它也会确保 S 必须是 string。如果 S 不是 string,就会走 false 分支,在这些情况下即 never。
更多详情,你可以在 GitHub 上了解这项更改。
类型参数的可选变型标注
我们来看以下类型。
tsinterface 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 修饰符。
tstype Getter<out T> = () => T;
同样,如果我们还想明确表示 Setter 在 T 上是逆变的,可以给它加上 in 修饰符。
tstype Setter<in T> = (value: T) => void;
这里使用 out 和 in,是因为类型参数的变型取决于它用在输出位置还是输入位置。你不必从变型的角度思考,只需考虑 T 是否用在输出和输入位置。
也有同时使用 in 和 out 的情况。
tsinterface State<in out T> {get: () => T;set: (value: T) => void;}
当 T 同时用在输出和输入位置时,它就变成不变的。两个不同的 State<T> 除非它们的 T 相同,否则不能互换。换句话说,State<Dog> 和 State<Animal> 不能相互替代。
严格来说,在纯结构化类型系统中,类型参数及其变型并不真正重要——你只需把类型代入每个类型参数的位置,并检查每个匹配的成员在结构上是否兼容。那么,如果 TypeScript 使用结构化类型系统,我们为什么还关心类型参数的变型?又为什么可能想要标注它们呢?
一个原因是,让读者一眼就能明确看出类型参数是如何使用的,这会很有帮助。对于复杂得多的类型,很难判断某个类型是用于读取、写入还是两者兼有。如果我们忘记说明该类型参数的使用方式,TypeScript 也会帮我们指出来。例如,如果我们忘记在 State 上同时指定 in 和 out,就会得到错误。
tsinterface 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 已经尝试推断类型参数的变型作为优化。这样做可以在合理时间内检查更大的结构化类型。提前计算变型让类型检查器可以跳过更深的比较,只比较类型实参,这比反复比较类型的完整结构快得多。但很多时候这种计算仍然相当昂贵,而且计算可能发现无法准确解决的循环,这意味着对于某个类型的变型没有明确答案。
tstype 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", ""]}}
给定上面的配置,像下面这样的导入……
tsimport * 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() 类型上。
tsexport 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 文件中的类型声明时,这个功能最有用。

你可以在最新版本的 Visual Studio Code 中试用这个新命令。不过要注意,此功能仍处于预览阶段,并且存在一些已知限制。在某些情况下,TypeScript 会使用启发式方法猜测哪个 .js 文件对应于给定的定义结果,因此这些结果可能并不准确。Visual Studio Code 目前也还不会提示某个结果是否只是猜测,但这是我们正在合作改进的地方。
你可以在专门的反馈 issue 中留下关于此功能的反馈、了解已知限制,或获取更多信息。
按分组整理导入
TypeScript 为 JavaScript 和 TypeScript 都提供了整理导入编辑器功能。遗憾的是,它有时像个不太精细的工具,常常只是草率地对导入语句排序。
例如,如果你对下面的文件运行整理导入……
ts// local codeimport * as bbb from "./bbb";import * as ccc from "./ccc";import * as aaa from "./aaa";// built-insimport * as path from "path";import * as child_process from "child_process"import * as fs from "fs";// some code...
你会得到类似下面的结果
ts// local codeimport * as child_process from "child_process";import * as fs from "fs";// built-insimport * 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 codeimport * as aaa from "./aaa";import * as bbb from "./bbb";import * as ccc from "./ccc";// built-insimport * as child_process from "child_process";import * as fs from "fs";import * as path from "path";// some code...
对象方法片段补全
TypeScript 现在为对象字面量方法提供片段补全。在对象中补全成员时,TypeScript 会为方法名单独提供一个常规补全项,同时为完整的方法定义提供另一个补全项!

更多详情,请参见实现此功能的 pull request。
破坏性变更
lib.d.ts 更新
尽管 TypeScript 努力避免重大破坏,但即便是内置库中的细微更改也可能引发问题。我们不认为 DOM 和 lib.d.ts 的更新会导致重大破坏,但可能会有一些小问题。
JSX 中更严格的展开检查
在 JSX 中编写 ...spread 时,TypeScript 现在会执行更严格的检查,确保给定的类型确实是对象。因此,类型为 unknown 和 never 的值(更少见的情况是裸的 null 和 undefined)不能再展开到 JSX 元素中。
因此,对于下面的示例:
tsximport * 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 中会触发运行时错误。
jslet str = `hello ${Symbol()}`;// TypeError: Cannot convert a Symbol value to a string
因此,TypeScript 也会报错;不过,TypeScript 现在还检查在模板字符串中使用的泛型值是否以某种方式被约束为 symbol。
tsfunction 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 调用中,从而绕开这个问题。
tsfunction 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 ...:
tsfunction 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。对于固定长度的元组,这几乎从未被察觉到,但这是一个疏漏,在带有尾部可选元素和剩余元素类型的元组上可以被观察到。
因此,下面的代码现在会报错:
tsfunction overwriteLength(tuple: readonly [string, string, string]) {// Now errors.tuple.length = 7;}
你可以在此处进一步了解这项更改。