TypeScript 5.2
using 声明与显式资源管理
TypeScript 5.2 增加了对即将推出的 ECMAScript 中显式资源管理特性的支持。 让我们探讨一下一些动机,并理解这个特性给我们带来了什么。
在创建对象后,通常需要执行某种“清理”工作。 例如,你可能需要关闭网络连接、删除临时文件,或者只是释放一些内存。
让我们设想一个创建临时文件的函数,在各项操作中读取和写入它,然后关闭并删除它。
import * as fs from "fs";
export function doSomeWork() {
const path = ".some_temp_file";
const file = fs.openSync(path, "w+");
// 使用文件...
// 关闭文件并删除它。
fs.closeSync(file);
fs.unlinkSync(path);
}这没问题,但如果我们需要提前退出呢?
export function doSomeWork() {
const path = ".some_temp_file";
const file = fs.openSync(path, "w+");
// 使用文件...
if (someCondition()) {
// 做一些更多的工作...
// 关闭文件并删除它。
fs.closeSync(file);
fs.unlinkSync(path);
return;
}
// 关闭文件并删除它。
fs.closeSync(file);
fs.unlinkSync(path);
}我们开始看到一些清理工作的重复,很容易被忘记。 如果抛出错误,我们也不能保证会关闭和删除文件。 这可以通过将所有内容包装在 try/finally 块中来解决。
export function doSomeWork() {
const path = ".some_temp_file";
const file = fs.openSync(path, "w+");
try {
// 使用文件...
if (someCondition()) {
// 做一些更多的工作...
return;
}
}
finally {
// 关闭文件并删除它。
fs.closeSync(file);
fs.unlinkSync(path);
}
}虽然这样更健壮,但给我们的代码增加了不少“噪音”。 如果我们在 finally 块中添加更多的清理逻辑,还可能遇到其他陷阱——例如,异常阻止了其他资源被释放。 这就是显式资源管理提案旨在解决的问题。 该提案的关键思想是支持资源释放——我们试图处理的这种清理工作——作为 JavaScript 中的一等概念。
这首先添加了一个新的内置 symbol,称为 Symbol.dispose,我们可以创建带有名为 Symbol.dispose 的方法的对象。 为方便起见,TypeScript 定义了一个新的全局类型 Disposable 来描述这些对象。
class TempFile implements Disposable {
#path: string;
#handle: number;
constructor(path: string) {
this.#path = path;
this.#handle = fs.openSync(path, "w+");
}
// 其他方法
[Symbol.dispose]() {
// 关闭文件并删除它。
fs.closeSync(this.#handle);
fs.unlinkSync(this.#path);
}
}稍后我们可以调用这些方法。
export function doSomeWork() {
const file = new TempFile(".some_temp_file");
try {
// ...
}
finally {
file[Symbol.dispose]();
}
}将清理逻辑移动到 TempFile 本身并没有给我们带来太多好处; 我们基本上只是将所有清理工作从 finally 块移到了一个方法中,这总是可行的。 但是,为这个方法提供一个众所周知的“名称”意味着 JavaScript 可以在其之上构建其他特性。
这就引出了该特性的第一个明星:using 声明! using 是一个新的关键字,允许我们声明新的固定绑定,有点像 const。 关键区别在于,使用 using 声明的变量会在作用域结束时调用其 Symbol.dispose 方法!
因此,我们可以简单地将代码写成这样:
export function doSomeWork() {
using file = new TempFile(".some_temp_file");
// 使用文件...
if (someCondition()) {
// 做一些更多的工作...
return;
}
}看看吧——没有 try/finally 块!至少,我们没有看到任何这样的块。 在功能上,这正是 using 声明会为我们做的,但我们不必处理它。
你可能熟悉 C# 中的 using 声明、Python 中的 with 语句或 Java 中的 try-with-resource 声明。 这些都类似于 JavaScript 的新 using 关键字,并提供了一种在作用域结束时执行对象“清理”的显式方式。
using 声明在其包含的作用域的最末端或“提前返回”(如 return 或抛出错误)之前执行清理。 它们也按照后进先出的顺序(类似于栈)进行清理。
function loggy(id: string): Disposable {
console.log(`Creating ${id}`);
return {
[Symbol.dispose]() {
console.log(`Disposing ${id}`);
}
}
}
function func() {
using a = loggy("a");
using b = loggy("b");
{
using c = loggy("c");
using d = loggy("d");
}
using e = loggy("e");
return;
// 无法到达。
// 从未创建,从未释放。
using f = loggy("f");
}
func();
// Creating a
// Creating b
// Creating c
// Creating d
// Disposing d
// Disposing c
// Creating e
// Disposing e
// Disposing b
// Disposing ausing 声明应该能够抵御异常; 如果抛出错误,它会在释放后重新抛出。 另一方面,你的函数体可能按预期执行,但 Symbol.dispose 可能会抛出异常。 在这种情况下,该异常也会被重新抛出。
但是,如果在释放之前的逻辑和释放过程中都抛出错误,会发生什么? 对于这些情况,引入了 SuppressedError 作为 Error 的一个新子类型。 它有一个 suppressed 属性,保存最后抛出的错误,以及一个 error 属性,保存最近抛出的错误。
class ErrorA extends Error {
name = "ErrorA";
}
class ErrorB extends Error {
name = "ErrorB";
}
function throwy(id: string) {
return {
[Symbol.dispose]() {
throw new ErrorA(`Error from ${id}`);
}
};
}
function func() {
using a = throwy("a");
throw new ErrorB("oops!")
}
try {
func();
}
catch (e: any) {
console.log(e.name); // SuppressedError
console.log(e.message); // An error was suppressed during disposal.
console.log(e.error.name); // ErrorA
console.log(e.error.message); // Error from a
console.log(e.suppressed.name); // ErrorB
console.log(e.suppressed.message); // oops!
}你可能已经注意到,我们在这些示例中使用的是同步方法。 然而,许多资源释放涉及异步操作,我们需要等待它们完成才能继续运行任何其他代码。
这就是为什么还有一个新的 Symbol.asyncDispose,它把我们引向下一个明星——await using 声明。 这些与 using 声明类似,但关键区别在于它们查找的释放必须被 await。 它们使用由 Symbol.asyncDispose 命名的方法,但也可以处理任何具有 Symbol.dispose 的对象。 为方便起见,TypeScript 还引入了一个名为 AsyncDisposable 的全局类型,用于描述任何具有异步释放方法的对象。
async function doWork() {
// 模拟半秒的工作。
await new Promise(resolve => setTimeout(resolve, 500));
}
function loggy(id: string): AsyncDisposable {
console.log(`Constructing ${id}`);
return {
async [Symbol.asyncDispose]() {
console.log(`Disposing (async) ${id}`);
await doWork();
},
}
}
async function func() {
await using a = loggy("a");
await using b = loggy("b");
{
await using c = loggy("c");
await using d = loggy("d");
}
await using e = loggy("e");
return;
// 无法到达。
// 从未创建,从未释放。
await using f = loggy("f");
}
func();
// Constructing a
// Constructing b
// Constructing c
// Constructing d
// Disposing (async) d
// Disposing (async) c
// Constructing e
// Disposing (async) e
// Disposing (async) b
// Disposing (async) a如果你期望其他人一致地执行清理逻辑,那么根据 Disposable 和 AsyncDisposable 定义类型可以使你的代码更容易使用。 实际上,现存有许多类型具有 dispose() 或 close() 方法。 例如,Visual Studio Code API 甚至定义了自己的 Disposable 接口。 浏览器和运行时(如 Node.js、Deno 和 Bun)中的 API 也可能选择为已有清理方法的对象(如文件句柄、连接等)使用 Symbol.dispose 和 Symbol.asyncDispose。
现在,也许所有这些听起来对库来说很棒,但对你的场景来说可能有点重量级。 如果你做很多临时性的清理工作,创建一个新类型可能会引入过多的抽象,并引发关于最佳实践的疑问。 例如,再看一下我们的 TempFile 示例。
class TempFile implements Disposable {
#path: string;
#handle: number;
constructor(path: string) {
this.#path = path;
this.#handle = fs.openSync(path, "w+");
}
// 其他方法
[Symbol.dispose]() {
// 关闭文件并删除它。
fs.closeSync(this.#handle);
fs.unlinkSync(this.#path);
}
}
export function doSomeWork() {
using file = new TempFile(".some_temp_file");
// 使用文件...
if (someCondition()) {
// 做一些更多的工作...
return;
}
}我们只是想记得调用两个函数——但这是最好的写法吗? 我们应该在构造函数中调用 openSync,创建一个 open() 方法,还是自己传入句柄? 我们应该为我们需要执行的每个可能的操作暴露一个方法,还是应该只将属性公开?
这就引出了该特性的最后两个明星:DisposableStack 和 AsyncDisposableStack。 这些对象对于执行一次性清理以及任意数量的清理都很有用。 DisposableStack 是一个对象,它有几种方法来跟踪 Disposable 对象,并且可以接收函数来执行任意清理工作。 我们还可以将它们分配给 using 变量,因为——你猜怎么着——它们也是 Disposable! 所以,这是我们如何编写原始示例的方法。
function doSomeWork() {
const path = ".some_temp_file";
const file = fs.openSync(path, "w+");
using cleanup = new DisposableStack();
cleanup.defer(() => {
fs.closeSync(file);
fs.unlinkSync(path);
});
// 使用文件...
if (someCondition()) {
// 做一些更多的工作...
return;
}
// ...
}这里,defer() 方法只是接受一个回调,该回调将在 cleanup 被释放时运行。 通常,defer(以及 DisposableStack 的其他方法,如 use 和 adopt)应该在创建资源后立即调用。 顾名思义,DisposableStack 以后进先出的顺序(如栈)释放它跟踪的所有内容,因此在创建值后立即 defer 有助于避免奇怪的依赖问题。 AsyncDisposableStack 类似,但可以跟踪 async 函数和 AsyncDisposable,并且它本身就是一个 AsyncDisposable。
defer 方法在许多方面类似于 Go、Swift、Zig、Odin 等语言中的 defer 关键字,约定应类似。
由于此特性非常新,大多数运行时不会原生支持它。 要使用它,你需要为以下内容提供运行时 polyfill:
Symbol.disposeSymbol.asyncDisposeDisposableStackAsyncDisposableStackSuppressedError
然而,如果你只关心 using 和 await using,你应该能够只 polyfill 内置的 symbol 就能解决问题。 对于大多数情况,像下面这样简单的代码应该可行:
Symbol.dispose ??= Symbol("Symbol.dispose");
Symbol.asyncDispose ??= Symbol("Symbol.asyncDispose");你还需要将编译 target 设置为 es2022 或更低,并将 lib 设置配置为包含 "esnext" 或 "esnext.disposable"。
{
"compilerOptions": {
"target": "es2022",
"lib": ["es2022", "esnext.disposable", "dom"]
}
}有关此特性的更多信息,请查看 GitHub 上的工作!
装饰器元数据
TypeScript 5.2 实现了一个即将推出的 ECMAScript 特性,称为装饰器元数据。
此特性的关键思想是使装饰器能够轻松地在它们所使用或所在的任何类上创建和使用元数据。
现在,当使用装饰器函数时,它们可以通过其上下文对象访问一个新的 metadata 属性。 metadata 属性只是一个简单的对象。 由于 JavaScript 允许我们任意添加属性,它可以被用作一个字典,由每个装饰器更新。 或者,由于每个被装饰的类部分的 metadata 对象都是相同的,它可以用作 Map 的键。 在类上或类内的所有装饰器运行完毕后,该对象可以通过 Symbol.metadata 在类上访问。
interface Context {
name: string;
metadata: Record<PropertyKey, unknown>;
}
function setMetadata(_target: any, context: Context) {
context.metadata[context.name] = true;
}
class SomeClass {
@setMetadata
foo = 123;
@setMetadata
accessor bar = "hello!";
@setMetadata
baz() { }
}
const ourMetadata = SomeClass[Symbol.metadata];
console.log(JSON.stringify(ourMetadata));
// { "bar": true, "baz": true, "foo": true }这在许多不同的场景中可能很有用。 元数据可能被附加用于多种用途,如调试、序列化或使用装饰器执行依赖注入。 由于元数据对象是为每个被装饰的类创建的,框架可以私下将它们用作 Map 或 WeakMap 的键,或者在必要时添加属性。
例如,假设我们想使用装饰器来跟踪在使用 JSON.stringify 时哪些属性和访问器是可序列化的,如下所示:
import { serialize, jsonify } from "./serializer";
class Person {
firstName: string;
lastName: string;
@serialize
age: number
@serialize
get fullName() {
return `${this.firstName} ${this.lastName}`;
}
toJSON() {
return jsonify(this)
}
constructor(firstName: string, lastName: string, age: number) {
// ...
}
}这里的意图是只有 age 和 fullName 应该被序列化,因为它们被 @serialize 装饰器标记。 我们为此目的定义了一个 toJSON 方法,但它只是调用 jsonify,后者使用 @serialize 创建的元数据。
以下是模块 ./serialize.ts 可能如何定义的示例:
const serializables = Symbol();
type Context =
| ClassAccessorDecoratorContext
| ClassGetterDecoratorContext
| ClassFieldDecoratorContext
;
export function serialize(_target: any, context: Context): void {
if (context.static || context.private) {
throw new Error("Can only serialize public instance members.")
}
if (typeof context.name === "symbol") {
throw new Error("Cannot serialize symbol-named properties.");
}
const propNames =
(context.metadata[serializables] as string[] | undefined) ??= [];
propNames.push(context.name);
}
export function jsonify(instance: object): string {
const metadata = instance.constructor[Symbol.metadata];
const propNames = metadata?.[serializables] as string[] | undefined;
if (!propNames) {
throw new Error("No members marked with @serialize.");
}
const pairStrings = propNames.map(key => {
const strKey = JSON.stringify(key);
const strValue = JSON.stringify((instance as any)[key]);
return `${strKey}: ${strValue}`;
});
return `{ ${pairStrings.join(", ")} }`;
}这个模块有一个名为 serializables 的本地 symbol,用于存储和检索标记为 @serializable 的属性名称。 它在每次调用 @serializable 时将这些属性名称的列表存储在元数据上。 当调用 jsonify 时,属性列表从元数据中获取,并用于从实例中检索实际值,最终序列化这些名称和值。
使用 symbol 在技术上使这些数据可被他人访问。 另一种替代方法是使用 WeakMap,以元数据对象作为键。 这样可以将数据保持私有,并且在这种情况下恰好使用更少的类型断言,但其他方面类似。
const serializables = new WeakMap<object, string[]>();
type Context =
| ClassAccessorDecoratorContext
| ClassGetterDecoratorContext
| ClassFieldDecoratorContext
;
export function serialize(_target: any, context: Context): void {
if (context.static || context.private) {
throw new Error("Can only serialize public instance members.")
}
if (typeof context.name !== "string") {
throw new Error("Can only serialize string properties.");
}
let propNames = serializables.get(context.metadata);
if (propNames === undefined) {
serializables.set(context.metadata, propNames = []);
}
propNames.push(context.name);
}
export function jsonify(instance: object): string {
const metadata = instance.constructor[Symbol.metadata];
const propNames = metadata && serializables.get(metadata);
if (!propNames) {
throw new Error("No members marked with @serialize.");
}
const pairStrings = propNames.map(key => {
const strKey = JSON.stringify(key);
const strValue = JSON.stringify((instance as any)[key]);
return `${strKey}: ${strValue}`;
});
return `{ ${pairStrings.join(", ")} }`;
}需要注意的是,这些实现没有处理子类和继承。 这留作你的练习(你可能会发现,在文件的一个版本中比在另一个版本中更容易实现!)。
由于这个特性还很新,大多数运行时不会原生支持它。 要使用它,你需要为 Symbol.metadata 提供一个 polyfill。 对于大多数情况,像下面这样简单的代码应该可行:
Symbol.metadata ??= Symbol("Symbol.metadata");你还需要将编译 target 设置为 es2022 或更低,并将 lib 设置配置为包含 "esnext" 或 "esnext.decorators"。
{
"compilerOptions": {
"target": "es2022",
"lib": ["es2022", "esnext.decorators", "dom"]
}
}我们要感谢 Oleksandr Tarasiuk 为 TypeScript 5.2 贡献了装饰器元数据的实现!
具名和匿名元组元素
元组类型支持为每个元素提供可选的标签或名称。
type Pair<T> = [first: T, second: T];这些标签不会改变你可以对它们进行的操作——它们仅仅是为了帮助提高可读性和工具支持。
然而,TypeScript 之前有一条规则,即元组不能在带标签和未带标签的元素之间混用。 换句话说,要么元组中没有元素有标签,要么所有元素都需要有标签。
// ✅ 没问题 - 没有标签
type Pair1<T> = [T, T];
// ✅ 没问题 - 全部有标签
type Pair2<T> = [first: T, second: T];
// ❌ 以前是错误
type Pair3<T> = [first: T, T];
// ~
// 元组成员必须全部有名称或全部没有名称。这对于剩余元素可能很烦人,因为我们会被迫添加一个标签,比如 rest 或 tail。
// ❌ 以前是错误
type TwoOrMore_A<T> = [first: T, second: T, ...T[]];
// ~~~~~~
// 元组成员必须全部有名称或全部没有名称。
// ✅
type TwoOrMore_B<T> = [first: T, second: T, rest: ...T[]];这也意味着必须在类型系统内部强制执行此限制,从而导致 TypeScript 会丢失标签。
type HasLabels = [a: string, b: string];
type HasNoLabels = [number, number];
type Merged = [...HasNoLabels, ...HasLabels];
// ^ [number, number, string, string]
//
// 在 'Merged' 中丢失了 'a' 和 'b'在 TypeScript 5.2 中,元组标签的全有或全无限制已被取消。 现在,语言在将标签展开到无标签元组时也可以保留标签。
我们要感谢 Josh Goldberg 和 Mateusz Burzyński 合作解除了这一限制。
简化数组联合的方法使用
在以前版本的 TypeScript 中,在数组联合上调用方法可能会导致痛苦。
declare let array: string[] | number[];
array.filter(x => !!x);
// ~~~~~~ 错误!
// 此表达式不可调用。
// 联合类型 '...' 的每个成员都有签名,
// 但这些签名彼此不兼容。在此示例中,TypeScript 会尝试查看每个版本的 filter 是否在 string[] 和 number[] 之间兼容。 由于没有一致的策略,TypeScript 只好放弃并说“我无法让它工作”。
在 TypeScript 5.2 中,在这些情况下放弃之前,数组联合会被视为一种特殊情况。 一个新的数组类型由每个成员的元素类型构造而成,然后在该数组上调用该方法。
以上面的例子为例,string[] | number[] 被转换为 (string | number)[](或 Array<string | number>),然后 filter 在该类型上调用。 有一个小的注意事项,即 filter 会产生一个 Array<string | number> 而不是 string[] | number[]; 但对于一个新鲜生成的值来说,发生“错误”的风险较小。
这意味着许多方法,如 filter、find、some、every 和 reduce,在之前无法调用的数组联合上现在都可以调用了。
你可以在实现拉取请求中阅读更多详细信息。
带有 TypeScript 实现文件扩展名的仅类型导入路径
TypeScript 现在允许在仅类型导入路径中包含声明和实现文件扩展名,无论是否启用了 allowImportingTsExtensions。
这意味着你现在可以编写使用 .ts、.mts、.cts 和 .tsx 文件扩展名的 import type 语句。
import type { JustAType } from "./justTypes.ts";
export function f(param: JustAType) {
// ...
}这也意味着可以在 TypeScript 和 JavaScript(通过 JSDoc)中使用的 import() 类型可以使用这些文件扩展名。
/**
* @param {import("./justTypes.ts").JustAType} param
*/
export function f(param) {
// ...
}有关更多信息,请在此处查看更改。
对象成员的逗号补全
在向对象添加新属性时,很容易忘记添加逗号。 以前,如果你忘记逗号并请求自动补全,TypeScript 会令人困惑地给出不相关的补全结果。
TypeScript 5.2 现在在你缺少逗号时优雅地提供对象成员补全。 但是,为了跳过语法错误,它还会自动插入缺失的逗号。

有关更多信息,请在此处查看实现。
内联变量重构
TypeScript 5.2 现在有一个重构,可以将变量的内容内联到所有使用点。
。
使用“内联变量”重构将消除该变量,并将变量的所有使用点替换为它的初始化器。 请注意,这可能会导致初始化器的副作用在不同的时间运行,并且运行的次数与变量被使用的次数相同。
有关更多详细信息,请参阅实现拉取请求。
持续类型兼容性检查的优化
由于 TypeScript 是结构类型系统,类型有时需要以成员方式进行比较; 然而,递归类型在这里增加了一些问题。 例如:
interface A {
value: A;
other: string;
}
interface B {
value: B;
other: number;
}当检查类型 A 是否与类型 B 兼容时,TypeScript 最终会检查 A 和 B 中的 value 类型是否分别兼容。 此时,类型系统需要停止进一步检查,并继续检查其他成员。 为此,类型系统必须跟踪任何两个类型是否已经在关联中。
以前,TypeScript 已经保留了一个类型对的栈,并迭代该栈以确定这些类型是否正在被关联。 当这个栈很浅时,这不是问题;但当栈不浅时,那,嗯,就是个问题。
在 TypeScript 5.3 中,一个简单的 Set 有助于跟踪此信息。 这使报告的一个使用 drizzle 库的测试用例所花费的时间减少了超过 33%!
Benchmark 1: old
Time (mean ± σ): 3.115 s ± 0.067 s [User: 4.403 s, System: 0.124 s]
Range (min … max): 3.018 s … 3.196 s 10 runs
Benchmark 2: new
Time (mean ± σ): 2.072 s ± 0.050 s [User: 3.355 s, System: 0.135 s]
Range (min … max): 1.985 s … 2.150 s 10 runs
Summary
'new' ran
1.50 ± 0.05 times faster than 'old'破坏性更改和正确性修复
TypeScript 努力不引入不必要的破坏; 然而,有时我们必须进行修正和改进,以便更好地分析代码。
lib.d.ts 更改
为 DOM 生成的类型可能会影响你的代码库。 有关更多信息,请查看 TypeScript 5.2 的 DOM 更新。
labeledElementDeclarations 可能包含 undefined 元素
为了支持有标签和无标签元素的混合,TypeScript 的 API 略有变化。 TupleType 的 labeledElementDeclarations 属性在元素无标签的位置可能包含 undefined。
interface TupleType {
- labeledElementDeclarations?: readonly (NamedTupleMember | ParameterDeclaration)[];
+ labeledElementDeclarations?: readonly (NamedTupleMember | ParameterDeclaration | undefined)[];
}在较新的 Node.js 设置下,module 和 moduleResolution 必须匹配
--module 和 --moduleResolution 选项各自支持 node16 和 nodenext 设置。 这些实际上是“现代 Node.js”设置,应该在任何较新的 Node.js 项目中使用。 我们发现,当这两个选项在使用 Node.js 相关设置方面不一致时,项目实际上是配置错误的。
在 TypeScript 5.2 中,当为 --module 和 --moduleResolution 选项中的任一个使用 node16 或 nodenext 时,TypeScript 现在要求另一个也具有类似的 Node.js 相关设置。 在设置不一致的情况下,你可能会收到如下错误消息:
Option 'moduleResolution' must be set to 'NodeNext' (or left unspecified) when option 'module' is set to 'NodeNext'.或者
Option 'module' must be set to 'Node16' when option 'moduleResolution' is set to 'Node16'.因此,例如 --module esnext --moduleResolution node16 将被拒绝——但你可能最好只使用 --module nodenext,或者使用 --module esnext --moduleResolution bundler。
有关更多信息,请在此处查看更改。
合并符号的导出检查一致性
当两个声明合并时,它们必须在是否都导出一致。 由于一个错误,TypeScript 在环境上下文中遗漏了特定情况,比如在声明文件或 declare module 块中。 例如,它不会对以下情况发出错误,其中 replaceInFile 一次被声明为导出的函数,一次被声明为未导出的命名空间。
declare module 'replace-in-file' {
export function replaceInFile(config: unknown): Promise<unknown[]>;
export {};
namespace replaceInFile {
export function sync(config: unknown): unknown[];
}
}在环境模块中,添加 export { ... } 或类似的构造(如 export default ...)会隐式地改变是否所有声明都自动导出。 TypeScript 现在更一致地识别了这些令人困惑的语义,并对 replaceInFile 的所有声明需要在修饰符上达成一致的事实发出错误,并将发出以下错误:
Individual declarations in merged declaration 'replaceInFile' must be all exported or all local.有关更多信息,请在此处查看更改。