TypeScript 5.7
对从未初始化的变量的检查
长期以来,TypeScript 一直能够捕获变量在所有先前分支中尚未初始化的问题。
let result: number
if (someCondition()) {
result = doSomeWork();
}
else {
let temporaryWork = doSomeWork();
temporaryWork *= 2;
// 忘记赋值给 'result'
}
console.log(result); // 错误:变量 'result' 在赋值前被使用。不幸的是,有些地方这种分析不起作用。 例如,如果在单独的函数中访问该变量,类型系统不知道函数何时会被调用,而是采取“乐观”的观点,认为变量将被初始化。
function foo() {
let result: number
if (someCondition()) {
result = doSomeWork();
}
else {
let temporaryWork = doSomeWork();
temporaryWork *= 2;
// 忘记赋值给 'result'
}
printResult();
function printResult() {
console.log(result); // 这里没有错误。
}
}虽然 TypeScript 5.7 对可能已初始化的变量仍然宽容,但类型系统能够在变量从未被初始化时报告错误。
function foo() {
let result: number
// 做工作,但忘记赋值给 'result'
function printResult() {
console.log(result); // 错误:变量 'result' 在赋值前被使用。
}
}相对路径的重写
有几种工具和运行时允许你“就地”运行 TypeScript 代码,这意味着它们不需要生成输出 JavaScript 文件的构建步骤。 例如,ts-node、tsx、Deno 和 Bun 都支持直接运行 .ts 文件。 最近,Node.js 也在研究通过 --experimental-strip-types(即将取消标志!)和 --experimental-transform-types 来支持此功能。 这非常方便,因为它允许我们更快地迭代,而无需担心重新运行构建任务。
然而,在使用这些模式时需要注意一些复杂性。 为了与所有这些工具保持最大兼容性,一个“就地”导入的 TypeScript 文件必须在运行时使用适当的 TypeScript 扩展名导入。 例如,要导入名为 foo.ts 的文件,我们必须在 Node 的新实验性支持中编写以下代码:
// main.ts
import * as foo from "./foo.ts"; // <- 这里我们需要 foo.ts,而不是 foo.js通常,如果我们这样做,TypeScript 会报错,因为它期望我们导入输出文件。 由于某些工具确实允许 .ts 导入,TypeScript 已经通过一个名为 --allowImportingTsExtensions 的选项支持这种导入风格有一段时间了。 这工作正常,但如果我们确实需要从这些 .ts 文件生成 .js 文件,会发生什么? 这是库作者的需求,他们需要能够只分发 .js 文件,但到目前为止 TypeScript 一直避免重写任何路径。
为了支持这种情况,我们添加了一个新的编译器选项,称为 --rewriteRelativeImportExtensions。 当导入路径是相对的(以 ./ 或 ../ 开头),以 TypeScript 扩展名(.ts、.tsx、.mts、.cts)结尾,并且是非声明文件时,编译器会将路径重写为对应的 JavaScript 扩展名(.js、.jsx、.mjs、.cjs)。
// 在 --rewriteRelativeImportExtensions 下...
// 这些将被重写。
import * as foo from "./foo.ts";
import * as bar from "../someFolder/bar.mts";
// 这些不会以任何方式被重写。
import * as a from "./foo";
import * as b from "some-package/file.ts";
import * as c from "@some-scope/some-package/file.ts";
import * as d from "#/file.ts";
import * as e from "./file.js";这使我们能够编写可以就地运行的 TypeScript 代码,并在准备好后将其编译为 JavaScript。
现在,我们注意到 TypeScript 通常避免重写路径。 这有几个原因,但最明显的是动态导入。 如果开发者编写以下代码,处理 import 接收的路径并非易事。事实上,不可能在任何依赖项中覆盖 import 的行为。
function getPath() {
if (Math.random() < 0.5) {
return "./foo.ts";
}
else {
return "./foo.js";
}
}
let myImport = await import(getPath());另一个问题是(正如我们上面看到的)只有相对路径会被重写,并且它们被“简单”地重写。 这意味着任何依赖于 TypeScript 的 baseUrl 和 paths 的路径都不会被重写:
// tsconfig.json
{
"compilerOptions": {
"module": "nodenext",
// ...
"paths": {
"@/*": ["./src/*"]
}
}
}// 不会被转换,不会工作。
import * as utilities from "@/utilities.ts";任何可能通过 package.json 的 exports 和 imports 字段解析的路径也不会被重写。
// package.json
{
"name": "my-package",
"imports": {
"#root/*": "./dist/*"
}
}// 不会被转换,不会工作。
import * as utilities from "#root/utilities.ts";因此,如果你一直在使用工作区风格的布局,多个包相互引用,你可能需要使用条件导出和作用域自定义条件来使其工作:
// my-package/package.json
{
"name": "my-package",
"exports": {
".": {
"@my-package/development": "./src/index.ts",
"import": "./lib/index.js"
},
"./*": {
"@my-package/development": "./src/*.ts",
"import": "./lib/*.js"
}
}
}任何时候你想导入 .ts 文件,都可以使用 node --conditions=@my-package/development 运行它。
请注意我们为条件 @my-package/development 使用的“命名空间”或“作用域”。 这是一个临时的解决方案,以避免来自也可能使用 development 条件的依赖项的冲突。 如果每个包都提供一个 development 条件,那么解析可能会尝试解析到一个不一定有效的 .ts 文件。 这个想法类似于 Colin McDonnell 的文章 Live types in a TypeScript monorepo 中描述的,以及 tshy 的从源代码加载指南。
有关此功能如何工作的更多细节,请在此处阅读更改。
支持 --target es2024 和 --lib es2024
TypeScript 5.7 现在支持 --target es2024,允许用户以 ECMAScript 2024 运行时为目标。 此目标主要启用了指定新的 --lib es2024,它包含了许多用于 SharedArrayBuffer 和 ArrayBuffer、Object.groupBy、Map.groupBy、Promise.withResolvers 等的功能。 它还将 Atomics.waitAsync 从 --lib es2022 移至 --lib es2024。
请注意,作为对 SharedArrayBuffer 和 ArrayBuffer 更改的一部分,两者现在略有分歧。 为了弥补差距并保留底层缓冲区类型,所有 TypedArray(如 Uint8Array 等)现在也是泛型的。
interface Uint8Array<TArrayBuffer extends ArrayBufferLike = ArrayBufferLike> {
// ...
}每个 TypedArray 现在都包含一个名为 TArrayBuffer 的类型参数,尽管该类型参数有一个默认类型参数,以便我们可以继续引用 Int32Array 而无需显式写出 Int32Array<ArrayBufferLike>。
如果你在此更新过程中遇到任何问题,可能需要更新 @types/node。
这项工作 主要由 Kenta Moriuchi 提供!
搜索祖先配置文件以确定项目所有权
当在 TSServer(如 Visual Studio 或 VS Code)的编辑器中加载 TypeScript 文件时,编辑器将尝试找到“拥有”该文件的相关 tsconfig.json 文件。 为此,它会从正在编辑的文件向上遍历目录树,查找任何名为 tsconfig.json 的文件。
以前,此搜索会在找到的第一个 tsconfig.json 文件处停止; 然而,假设项目结构如下:
project/
├── src/
│ ├── foo.ts
│ ├── foo-test.ts
│ ├── tsconfig.json
│ └── tsconfig.test.json
└── tsconfig.json这里,想法是 src/tsconfig.json 是项目的“主”配置文件,而 src/tsconfig.test.json 是用于运行测试的配置文件。
// src/tsconfig.json
{
"compilerOptions": {
"outDir": "../dist"
},
"exclude": ["**/*.test.ts"]
}// src/tsconfig.test.json
{
"compilerOptions": {
"outDir": "../dist/test"
},
"include": ["**/*.test.ts"],
"references": [
{ "path": "./tsconfig.json" }
]
}// tsconfig.json
{
// 这是一个“工作区风格”或“解决方案风格”的 tsconfig。
// 它不指定任何文件,只是引用所有实际项目。
"files": [],
"references": [
{ "path": "./src/tsconfig.json" },
{ "path": "./src/tsconfig.test.json" },
]
}这里的问题是,当编辑 foo-test.ts 时,编辑器会找到 project/src/tsconfig.json 作为“拥有”的配置文件——但这不是我们想要的! 如果搜索在此停止,这可能不是期望的结果。 以前避免这种情况的唯一方法是将 src/tsconfig.json 重命名为类似 src/tsconfig.src.json 的名称,这样所有文件都会命中引用每个可能项目的顶级 tsconfig.json。
project/
├── src/
│ ├── foo.ts
│ ├── foo-test.ts
│ ├── tsconfig.src.json
│ └── tsconfig.test.json
└── tsconfig.jsonTypeScript 5.7 现在不再强迫开发者这样做,而是继续向上遍历目录树,为编辑器场景找到其他合适的 tsconfig.json 文件。 这为项目组织方式和配置文件结构提供了更大的灵活性。
你可以在 GitHub 上此处和此处获得有关实现的更多细节。
编辑器中复合项目的更快速项目所有权检查
想象一个具有以下结构的大型代码库:
packages
├── graphics/
│ ├── tsconfig.json
│ └── src/
│ └── ...
├── sound/
│ ├── tsconfig.json
│ └── src/
│ └── ...
├── networking/
│ ├── tsconfig.json
│ └── src/
│ └── ...
├── input/
│ ├── tsconfig.json
│ └── src/
│ └── ...
└── app/
├── tsconfig.json
├── some-script.js
└── src/
└── ...packages 中的每个目录都是一个独立的 TypeScript 项目,而 app 目录是依赖于所有其他项目的主项目。
// app/tsconfig.json
{
"compilerOptions": {
// ...
},
"include": ["src"],
"references": [
{ "path": "../graphics/tsconfig.json" },
{ "path": "../sound/tsconfig.json" },
{ "path": "../networking/tsconfig.json" },
{ "path": "../input/tsconfig.json" }
]
}现在注意我们在 app 目录中有文件 some-script.js。 当我们在编辑器中打开 some-script.js 时,TypeScript 语言服务(它也处理 JavaScript 文件的编辑器体验!)必须确定该文件属于哪个项目,以便应用正确的设置。
在这种情况下,最近的 tsconfig.json 并不包含 some-script.js,但 TypeScript 会继续询问“app/tsconfig.json 引用的项目中是否有某个项目包含 some-script.js?”。 为此,TypeScript 以前会逐个加载每个项目,并在找到包含 some-script.js 的项目时立即停止。 即使 some-script.js 不包含在项目的根文件集中,TypeScript 仍然会解析项目中的所有文件,因为某些根文件集仍然可以传递地引用 some-script.js。
随着时间的推移,我们发现这种行为在较大的代码库中会导致极端且不可预测的行为。 开发者打开零散的脚本文件,却发现要等待整个代码库被打开。
值得庆幸的是,每个可以被另一个(非工作区)项目引用的项目都必须启用一个称为 composite 的标志,该标志强制执行所有输入源文件必须预先已知的规则。 因此,在探测一个 composite 项目时,TypeScript 5.7 只会检查文件是否属于该项目的根文件集。 这应该可以避免这种常见的最坏情况行为。
有关更多信息,请在此处查看更改。
--module nodenext 中经过验证的 JSON 导入
当在 --module nodenext 下从 .json 文件导入时,TypeScript 现在将强制执行某些规则以防止运行时错误。
首先,对于任何 JSON 文件导入,都需要存在包含 type: "json" 的导入属性。
import myConfig from "./myConfig.json";
// ~~~~~~~~~~~~~~~~~
// ❌ 错误:将 JSON 文件导入 ECMAScript 模块时,当 'module' 设置为 'NodeNext' 时需要 'type: "json"' 导入属性。
import myConfig from "./myConfig.json" with { type: "json" };
// ^^^^^^^^^^^^^^^^
// ✅ 这没问题,因为我们提供了 `type: "json"`除了此验证之外,TypeScript 不会生成“命名”导出,并且 JSON 导入的内容只能通过默认导出访问。
// ✅ 这没问题:
import myConfigA from "./myConfig.json" with { type: "json" };
let version = myConfigA.version;
///////////
import * as myConfigB from "./myConfig.json" with { type: "json" };
// ❌ 这不行:
let version = myConfig.version;
// ✅ 这没问题:
let version = myConfig.default.version;请在此处查看有关此更改的更多信息。
在 Node.js 中支持 V8 编译缓存
Node.js 22 支持一个名为 module.enableCompileCache() 的新 API。 此 API 允许运行时重用工具首次运行后完成的部分解析和编译工作。
TypeScript 5.7 现在利用此 API,以便更快地开始执行有用的工作。 在我们自己的测试中,我们观察到运行 tsc --version 的速度提升了约 2.5 倍。
Benchmark 1: node ./built/local/_tsc.js --version (*无*缓存)
Time (mean ± σ): 122.2 ms ± 1.5 ms [User: 101.7 ms, System: 13.0 ms]
Range (min … max): 119.3 ms … 132.3 ms 200 runs
Benchmark 2: node ./built/local/tsc.js --version (*有*缓存)
Time (mean ± σ): 48.4 ms ± 1.0 ms [User: 34.0 ms, System: 11.1 ms]
Range (min … max): 45.7 ms … 52.8 ms 200 runs
Summary
node ./built/local/tsc.js --version ran
2.52 ± 0.06 times faster than node ./built/local/_tsc.js --version有关更多信息,请在此处查看拉取请求。
值得注意的行为变化
本节重点介绍一组在升级时应该注意和理解的重要更改。 有时它会突出显示弃用、删除和新限制。 它还可能包含功能上改进的错误修复,但也可能通过引入新错误来影响现有构建。
lib.d.ts
为 DOM 生成的类型可能会对代码库的类型检查产生影响。 有关更多信息,请查看与此版本 TypeScript 的 DOM 和 lib.d.ts 更新相关的链接问题。
TypedArray 现在在 ArrayBufferLike 上是泛型的
在 ECMAScript 2024 中,SharedArrayBuffer 和 ArrayBuffer 的类型略有不同。 为了弥补差距并保留底层缓冲区类型,所有 TypedArray(如 Uint8Array 等)现在也是泛型的。
interface Uint8Array<TArrayBuffer extends ArrayBufferLike = ArrayBufferLike> {
// ...
}每个 TypedArray 现在都包含一个名为 TArrayBuffer 的类型参数,尽管该类型参数有一个默认类型参数,以便用户可以继续引用 Int32Array 而无需显式写出 Int32Array<ArrayBufferLike>。
如果你在此更新过程中遇到任何问题,例如
error TS2322: Type 'Buffer' is not assignable to type 'Uint8Array<ArrayBufferLike>'.
error TS2345: Argument of type 'Buffer' is not assignable to parameter of type 'Uint8Array<ArrayBufferLike>'.
error TS2345: Argument of type 'ArrayBufferLike' is not assignable to parameter of type 'ArrayBuffer'.
error TS2345: Argument of type 'Buffer' is not assignable to parameter of type 'string | ArrayBufferView | Stream | Iterable<string | ArrayBufferView> | AsyncIterable<string | ArrayBufferView>'.那么你可能需要更新 @types/node。
你可以在 GitHub 上阅读关于此更改的具体细节。
从类中的非字面量方法名称创建索引签名
TypeScript 现在对类中使用非字面量计算属性名称声明的方法具有更一致的行为。 例如,在以下代码中:
declare const symbolMethodName: symbol;
export class A {
[symbolMethodName]() { return 1 };
}以前 TypeScript 只是将类视为如下形式:
export class A {
}换句话说,从类型系统的角度来看,[symbolMethodName] 对 A 的类型没有任何贡献。
TypeScript 5.7 现在更有意义地看待方法 [symbolMethodName]() {},并生成一个索引签名。 因此,上述代码被解释为类似以下代码:
export class A {
[x: symbol]: () => number;
}这提供了与对象字面量中的属性和方法一致的行为。
对返回 null 和 undefined 的函数产生更多隐式 any 错误
当函数表达式被返回泛型类型的签名进行上下文类型化时,TypeScript 现在在 noImplicitAny 下但在 strictNullChecks 之外适当地提供隐式 any 错误。
declare var p: Promise<number>;
const p2 = p.catch(() => null);
// ~~~~~~~~~~
// error TS7011: 函数表达式,缺少返回类型注解,隐式具有 'any' 返回类型。