TypeScript 5.2

using 声明和显式资源管理

¥using Declarations and Explicit Resource Management

TypeScript 5.2 增加了对即将在 ECMAScript 中推出的 显式资源管理 功能的支持。让我们探索一些动机,并了解该功能为我们带来了什么。

¥TypeScript 5.2 adds support for the upcoming Explicit Resource Management feature in ECMAScript. Let’s explore some of the motivations and understand what the feature brings us.

创建对象后通常需要执行某种 “clean-up” 操作。例如,你可能需要关闭网络连接、删除临时文件或只是释放一些内存。

¥It’s common to need to do some sort of “clean-up” after creating an object. For example, you might need to close network connections, delete temporary files, or just free up some memory.

假设有一个函数创建一个临时文件,对其进行读写操作以进行各种操作,然后关闭并删除该文件。

¥Let’s imagine a function that creates a temporary file, reads and writes to it for various operations, and then closes and deletes it.

ts
import * as fs from "fs";
export function doSomeWork() {
const path = ".some_temp_file";
const file = fs.openSync(path, "w+");
// use file...
// Close the file and delete it.
fs.closeSync(file);
fs.unlinkSync(path);
}

这没问题,但如果我们需要提前退出会发生什么?

¥This is fine, but what happens if we need to perform an early exit?

ts
export function doSomeWork() {
const path = ".some_temp_file";
const file = fs.openSync(path, "w+");
// use file...
if (someCondition()) {
// do some more work...
// Close the file and delete it.
fs.closeSync(file);
fs.unlinkSync(path);
return;
}
// Close the file and delete it.
fs.closeSync(file);
fs.unlinkSync(path);
}

我们开始发现一些重复的清理操作,这些操作很容易被遗忘。如果抛出错误,我们也无法保证关闭并删除文件。可以通过将所有这些都封装在 try/finally 块中来解决。

¥We’re starting to see some duplication of clean-up which can be easy to forget. We’re also not guaranteed to close and delete the file if an error gets thrown. This could be solved by wrapping this all in a try/finally block.

ts
export function doSomeWork() {
const path = ".some_temp_file";
const file = fs.openSync(path, "w+");
try {
// use file...
if (someCondition()) {
// do some more work...
return;
}
}
finally {
// Close the file and delete it.
fs.closeSync(file);
fs.unlinkSync(path);
}
}

虽然这更加健壮,但它给我们的代码增加了不少麻烦。如果我们开始在 finally 块中添加更多清理逻辑,还可能遇到其他意外情况 - 例如,阻止其他资源被处置的异常。这就是 显式资源管理 提案旨在解决的问题。该提案的核心思想是将资源处置(我们正在尝试处理的清理工作)作为 JavaScript 中的首要理念。

¥While this is more robust, it’s added quite a bit of “noise” to our code. There are also other foot-guns we can run into if we start adding more clean-up logic to our finally block — for example, exceptions preventing other resources from being disposed. This is what the explicit resource management proposal aims to solve. The key idea of the proposal is to support resource disposal — this clean-up work we’re trying to deal with — as a first class idea in JavaScript.

这首先添加了一个名为 Symbol.dispose 的新内置 symbol,然后我们可以创建带有以 Symbol.dispose 命名的方法的对象。为了方便起见,TypeScript 定义了一个名为 Disposable 的新全局类型来描述这些对象。

¥This starts by adding a new built-in symbol called Symbol.dispose, and we can create objects with methods named by Symbol.dispose. For convenience, TypeScript defines a new global type called Disposable which describes these.

ts
class TempFile implements Disposable {
#path: string;
#handle: number;
constructor(path: string) {
this.#path = path;
this.#handle = fs.openSync(path, "w+");
}
// other methods
[Symbol.dispose]() {
// Close the file and delete it.
fs.closeSync(this.#handle);
fs.unlinkSync(this.#path);
}
}

稍后我们可以调用这些方法。

¥Later on we can call those methods.

ts
export function doSomeWork() {
const file = new TempFile(".some_temp_file");
try {
// ...
}
finally {
file[Symbol.dispose]();
}
}

将清理逻辑移到 TempFile 本身并没有太大用处;我们基本上将所有清理工作从 finally 块移到了一个方法中,这一直以来都是可行的。但拥有此方法的知名 “name” 意味着 JavaScript 可以在其基础上构建其他功能。

¥Moving the clean-up logic to TempFile itself doesn’t buy us much; we’ve basically just moved all the clean-up work from the finally block into a method, and that’s always been possible. But having a well-known “name” for this method means that JavaScript can build other features on top of it.

这让我们看到了该功能的第一颗星:using 个声明!using 是一个新关键字,允许我们声明新的固定绑定,类似于 const。关键区别在于,使用 using 声明的变量会在作用域末尾调用其 Symbol.dispose 方法!

¥That brings us to the first star of the feature: using declarations! using is a new keyword that lets us declare new fixed bindings, kind of like const. The key difference is that variables declared with using get their Symbol.dispose method called at the end of the scope!

所以我们可以简单地像这样编写代码:

¥So we could simply have written our code like this:

ts
export function doSomeWork() {
using file = new TempFile(".some_temp_file");
// use file...
if (someCondition()) {
// do some more work...
return;
}
}

检查一下 - 没有 try/finally 块!至少,我们没有看到这种情况。从功能上讲,这正是 using 声明能为我们做的事情,但我们不必处理这个问题。

¥Check it out — no try/finally blocks! At least, none that we see. Functionally, that’s exactly what using declarations will do for us, but we don’t have to deal with that.

你可能熟悉 using C# 中的声明Python 中的 with 语句Java 中的 try-with-resource 声明。它们都类似于 JavaScript 的新 using 关键字,并提供了一种类似的显式方法,可以在作用域末尾执行对象的 “tear-down” 操作。

¥You might be familiar with using declarations in C#, with statements in Python, or try-with-resource declarations in Java. These are all similar to JavaScript’s new using keyword, and provide a similar explicit way to perform a “tear-down” of an object at the end of a scope.

using 声明在其包含作用域的最末端或在 “提前返回”(例如 returnthrown 错误)发生之前执行此清理。它们也像堆栈一样按照先进后出的顺序进行处理。

¥using declarations do this clean-up at the very end of their containing scope or right before an “early return” like a return or a thrown error. They also dispose in a first-in-last-out order like a stack.

ts
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;
// Unreachable.
// Never created, never disposed.
using f = loggy("f");
}
func();
// Creating a
// Creating b
// Creating c
// Creating d
// Disposing d
// Disposing c
// Creating e
// Disposing e
// Disposing b
// Disposing a

using 声明应该能够抵御异常;如果抛出错误,则会在处理后重新抛出。另一方面,你的函数主体可能按预期执行,但 Symbol.dispose 可能会抛出错误。在这种情况下,该异常也会被重新抛出。

¥using declarations are supposed to be resilient to exceptions; if an error is thrown, it’s rethrown after disposal. On the other hand, the body of your function might execute as expected, but the Symbol.dispose might throw. In that case, that exception is rethrown as well.

但是,如果在处置之前和处置期间的逻辑都抛出了错误,会发生什么情况?针对这些情况,SuppressedError 已作为 Error 的新子类型引入。它具有一个 suppressed 属性,用于保存最后抛出的错误,以及一个 error 属性,用于保存最近抛出的错误。

¥But what happens if both the logic before and during disposal throws an error? For those cases, SuppressedError has been introduced as a new subtype of Error. It features a suppressed property that holds the last-thrown error, and an error property for the most-recently thrown error.

ts
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!
}

你可能已经注意到,我们在这些示例中使用了同步方法。但是,许多资源处置涉及异步操作,我们需要等待这些操作完成后才能继续运行任何其他代码。

¥You might have noticed that we’re using synchronous methods in these examples. However, lots of resource disposal involves asynchronous operations, and we need to wait for those to complete before we continue running any other code.

因此,还新增了 Symbol.asyncDispose,并将我们引向了下一个亮点 - await using 声明。这些类似于 using 声明,但关键在于它们会查找哪些组件必须由 await 处理。它们使用名为 Symbol.asyncDispose 的不同方法,尽管它们也可以对任何带有 Symbol.dispose 的对象进行操作。为了方便起见,TypeScript 还引入了一个名为 AsyncDisposable 的全局类型,用于描述任何具有异步 dispose 方法的对象。

¥That’s why there is also a new Symbol.asyncDispose, and it brings us to the next star of the show — await using declarations. These are similar to using declarations, but the key is that they look up whose disposal must be awaited. They use a different method named by Symbol.asyncDispose, though they can operate on anything with a Symbol.dispose as well. For convenience, TypeScript also introduces a global type called AsyncDisposable that describes any object with an asynchronous dispose method.

ts
async function doWork() {
// Do fake work for half a second.
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;
// Unreachable.
// Never created, never disposed.
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

如果你希望其他人始终如一地执行拆卸逻辑,那么根据 DisposableAsyncDisposable 定义类型可以使你的代码更易于使用。事实上,许多现有类型都具有 dispose()close() 方法。例如,Visual Studio Code API 甚至定义了 他们自己的 Disposable 接口。浏览器和 Node.js、Deno 和 Bun 等运行时中的 API 也可能选择对已经具有清理方法的对象使用 Symbol.disposeSymbol.asyncDispose,例如文件句柄、连接等。

¥Defining types in terms of Disposable and AsyncDisposable can make your code much easier to work with if you expect others to do tear-down logic consistently. In fact, lots of existing types exist in the wild which have a dispose() or close() method. For example, the Visual Studio Code APIs even define their own Disposable interface. APIs in the browser and in runtimes like Node.js, Deno, and Bun might also choose to use Symbol.dispose and Symbol.asyncDispose for objects which already have clean-up methods, like file handles, connections, and more.

现在,这些对于库来说可能听起来很棒,但对于你的场景来说,可能有点繁琐。如果你正在进行大量临时清理工作,创建新类型可能会引入大量过度抽象和最佳实践方面的问题。例如,再次以我们的 TempFile 为例。

¥Now maybe this all sounds great for libraries, but a little bit heavy-weight for your scenarios. If you’re doing a lot of ad-hoc clean-up, creating a new type might introduce a lot of over-abstraction and questions about best-practices. For example, take our TempFile example again.

ts
class TempFile implements Disposable {
#path: string;
#handle: number;
constructor(path: string) {
this.#path = path;
this.#handle = fs.openSync(path, "w+");
}
// other methods
[Symbol.dispose]() {
// Close the file and delete it.
fs.closeSync(this.#handle);
fs.unlinkSync(this.#path);
}
}
export function doSomeWork() {
using file = new TempFile(".some_temp_file");
// use file...
if (someCondition()) {
// do some more work...
return;
}
}

我们只想记住调用两个函数 - 但这是最好的写法吗?我们应该在构造函数中调用 openSync,创建 open() 方法,还是自己传入句柄?我们应该为每个需要执行的操作公开一个方法,还是应该将属性公开?

¥All we wanted was to remember to call two functions — but was this the best way to write it? Should we be calling openSync in the constructor, create an open() method, or pass in the handle ourselves? Should we expose a method for every possible operation we need to perform, or should we just make the properties public?

这让我们看到了该功能的最终亮点:DisposableStackAsyncDisposableStack。这些对象对于执行一次性清理以及任意数量的清理都非常有用。DisposableStack 是一个对象,它具有多种用于跟踪 Disposable 对象的方法,并且可以为其提供执行任意清理工作的函数。我们也可以将它们赋值给 using 变量,因为 - 注意 - 它们也是 Disposable!因此,我们可以这样编写原始示例。

¥That brings us to the final stars of the feature: DisposableStack and AsyncDisposableStack. These objects are useful for doing both one-off clean-up, along with arbitrary amounts of cleanup. A DisposableStack is an object that has several methods for keeping track of Disposable objects, and can be given functions for doing arbitrary clean-up work. We can also assign them to using variables because — get this — they’re also Disposable! So here’s how we could’ve written the original example.

ts
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);
});
// use file...
if (someCondition()) {
// do some more work...
return;
}
// ...
}

这里,defer() 方法只接受一个回调,该回调将在 cleanup 被释放后运行。通常情况下,defer(以及其他 DisposableStack 方法,如 useadopt)应该在创建资源后立即调用。顾名思义,DisposableStack 会像堆栈一样按先进后出的顺序处理它跟踪的所有内容,因此在创建值后立即进行 defer 操作有助于避免奇怪的依赖问题。AsyncDisposableStack 的工作原理类似,但可以跟踪 async 函数和 AsyncDisposable,并且本身就是一个 AsyncDisposable.

¥Here, the defer() method just takes a callback, and that callback will be run once cleanup is disposed of. Typically, defer (and other DisposableStack methods like use and adopt) should be called immediately after creating a resource. As the name suggests, DisposableStack disposes of everything it keeps track of like a stack, in a first-in-last-out order, so defering immediately after creating a value helps avoid odd dependency issues. AsyncDisposableStack works similarly, but can keep track of async functions and AsyncDisposables, and is itself an AsyncDisposable.

defer 方法在很多方面与 GoSwiftZigOdin 及其他语言中的 defer 关键字类似,其约定也应类似。

¥The defer method is similar in many ways to the defer keyword in Go, Swift, Zig, Odin, and others, where the conventions should be similar.

由于此功能刚刚推出,大多数运行时不会原生支持它。要使用它,你需要以下运行时 polyfill:

¥Because this feature is so recent, most runtimes will not support it natively. To use it, you will need runtime polyfills for the following:

  • Symbol.dispose

  • Symbol.asyncDispose

  • DisposableStack

  • AsyncDisposableStack

  • SuppressedError

但是,如果你只对 usingawait using 感兴趣,那么你应该可以只对内置的 symbol 进行 polyfill。大多数情况下,像下面这样简单的方法应该可以正常工作:

¥However, if all you’re interested in is using and await using, you should be able to get away with only polyfilling the built-in symbols. Something as simple as the following should work for most cases:

ts
Symbol.dispose ??= Symbol("Symbol.dispose");
Symbol.asyncDispose ??= Symbol("Symbol.asyncDispose");

你还需要将编译 target 设置为 es2022 或更低版本,并将 lib 设置配置为包含 "esnext""esnext.disposable"

¥You will also need to set your compilation target to es2022 or below, and configure your lib setting to either include "esnext" or "esnext.disposable".

json
{
"compilerOptions": {
"target": "es2022",
"lib": ["es2022", "esnext.disposable", "dom"]
}
}

有关此功能的更多信息,请参阅 查看 GitHub 上的工作

¥For more information on this feature, take a look at the work on GitHub!

装饰器元数据

¥Decorator Metadata

TypeScript 5.2 实现了 即将推出的 ECMAScript 功能,称为“装饰器元数据”

¥TypeScript 5.2 implements an upcoming ECMAScript feature called decorator metadata.

此功能的核心思想是使装饰器能够轻松地在其所用或所包含的任何类上创建和使用元数据。

¥The key idea of this feature is to make it easy for decorators to create and consume metadata on any class they’re used on or within.

每当使用装饰器函数时,它们现在可以访问其上下文对象上的新 metadata 属性。metadata 属性仅包含一个简单的对象。由于 JavaScript 允许我们任意添加属性,因此它可以用作字典,并由每个装饰器进行更新。或者,由于每个 metadata 对象对于类的每个修饰部分都是相同的,因此可以将其用作 Map 的键。类上或类内的所有装饰器运行完毕后,即可通过 Symbol.metadata 在类上访问该对象。

¥Whenever decorator functions are used, they now have access to a new metadata property on their context object. The metadata property just holds a simple object. Since JavaScript lets us add properties arbitrarily, it can be used as a dictionary that is updated by each decorator. Alternatively, since every metadata object will be identical for each decorated portion of a class, it can be used as a key into a Map. After all decorators on or in a class get run, that object can be accessed on the class via Symbol.metadata.

ts
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 }

这在许多不同的场景中都很有用。元数据可能被附加用于许多用途,例如调试、序列化或使用装饰器执行依赖注入。由于元数据对象是按修饰类创建的,因此框架可以私下将它们用作 MapWeakMap 的键,或者根据需要添加属性。

¥This can be useful in a number of different scenarios. Metadata could possibly be attached for lots of uses like debugging, serialization, or performing dependency injection with decorators. Since metadata objects are created per decorated class, frameworks can either privately use them as keys into a Map or WeakMap, or tack properties on as necessary.

例如,假设我们想使用装饰器来跟踪使用 JSON.stringify 时哪些属性和访问器是可序列化的,如下所示:

¥For example, let’s say we wanted to use decorators to keep track of which properties and accessors are serializable when using JSON.stringify like so:

ts
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) {
// ...
}
}

这里,意图是只有 agefullName 应该被序列化,因为它们被 @serialize 装饰器标记了。为此,我们定义了一个 toJSON 方法,但它只是调用 jsonify,而 jsonify 使用了 @serialize 创建的元数据。

¥Here, the intent is that only age and fullName should be serialized because they are marked with the @serialize decorator. We define a toJSON method for this purpose, but it just calls out to jsonify which uses the metadata that @serialize created.

以下是模块 ./serialize.ts 的定义方式示例:

¥Here’s an example of how the module ./serialize.ts might be defined:

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 时,将从元数据中获取属性列表,并用于从实例中检索实际值,最终序列化这些名称和值。

¥This module has a local symbol called serializables to store and retrieve the names of properties marked @serializable. It stores a list of these property names on the metadata on each invocation of @serializable. When jsonify is called, the list of properties is fetched off of the metadata and used to retrieve the actual values from the instance, eventually serializing those names and values.

从技术上讲,使用 symbol 可以使其他人访问这些数据。另一种方法是使用 WeakMap 修饰符,并以元数据对象为键。这可以保持数据的私密性,并且在这种情况下使用的类型断言更少,但其他方面类似。

¥Using a symbol technically makes this data accessible to others. An alternative might be to use a WeakMap using the metadata object as a key. This keeps data private and happens to use fewer type assertions in this case, but is otherwise similar.

ts
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(", ")} }`;
}

需要注意的是,这些实现不处理子类化和继承。这留给你做个练习(你可能会发现,在一个版本的文件中比在另一个版本中更容易!)。

¥As a note, these implementations don’t handle subclassing and inheritance. That’s left as an exercise to you (and you might find that it is easier in one version of the file than the other!).

由于此功能尚不成熟,大多数运行时环境尚不支持它。要使用它,你需要一个 Symbol.metadata 的 polyfill。大多数情况下,像下面这样简单的方法应该可以正常工作:

¥Because this feature is still fresh, most runtimes will not support it natively. To use it, you will need a polyfill for Symbol.metadata. Something as simple as the following should work for most cases:

ts
Symbol.metadata ??= Symbol("Symbol.metadata");

你还需要将编译 target 设置为 es2022 或更低版本,并将 lib 设置配置为包含 "esnext""esnext.decorators"

¥You will also need to set your compilation target to es2022 or below, and configure your lib setting to either include "esnext" or "esnext.decorators".

json
{
"compilerOptions": {
"target": "es2022",
"lib": ["es2022", "esnext.decorators", "dom"]
}
}

我们要感谢 Oleksandr Tarasiuk 为 TypeScript 5.2 贡献了 装饰器元数据的实现

¥We’d like to thank Oleksandr Tarasiuk for contributing the implementation of decorator metadata for TypeScript 5.2!

命名元组和匿名元组元素

¥Named and Anonymous Tuple Elements

元组类型支持每个元素的可选标签或名称。

¥Tuple types have supported optional labels or names for each element.

ts
type Pair<T> = [first: T, second: T];

这些标签不会改变你使用它们执行的操作 - 它们仅仅是为了提高可读性和工具性。

¥These labels don’t change what you’re allowed to do with them — they’re solely to help with readability and tooling.

然而,TypeScript 之前有一个规则,即元组不能在带标签和不带标签的元素之间混合使用。换句话说,要么元组中没有元素可以有标签,要么所有元素都需要一个标签。

¥However, TypeScript previously had a rule that tuples could not mix and match between labeled and unlabeled elements. In other words, either no element could have a label in a tuple, or all elements needed one.

ts
// ✅ fine - no labels
type Pair1<T> = [T, T];
// ✅ fine - all fully labeled
type Pair2<T> = [first: T, second: T];
// ❌ previously an error
type Pair3<T> = [first: T, T];
// ~
// Tuple members must all have names
// or all not have names.

对于剩余元素来说,这可能会很烦人,因为我们不得不添加像 resttail 这样的标签。

¥This could be annoying for rest elements where we’d be forced to just add a label like rest or tail.

ts
// ❌ previously an error
type TwoOrMore_A<T> = [first: T, second: T, ...T[]];
// ~~~~~~
// Tuple members must all have names
// or all not have names.
// ✅
type TwoOrMore_B<T> = [first: T, second: T, rest: ...T[]];

这也意味着此限制必须在类型系统内部强制执行,这意味着 TypeScript 会丢失标签。

¥It also meant that this restriction had to be enforced internally in the type system, meaning TypeScript would lose labels.

ts
type HasLabels = [a: string, b: string];
type HasNoLabels = [number, number];
type Merged = [...HasNoLabels, ...HasLabels];
// ^ [number, number, string, string]
//
// 'a' and 'b' were lost in 'Merged'

在 TypeScript 5.2 中,元组标签的全有或全无限制已被取消。该语言现在还可以在扩展到无标签元组时保留标签。

¥In TypeScript 5.2, the all-or-nothing restriction on tuple labels has been lifted. The language can now also preserve labels when spreading into an unlabeled tuple.

我们要感谢 Josh GoldbergMateusz Burzyński,他们贡献了 协作解除此限制

¥We’d like to extend our thanks to Josh Goldberg and Mateusz Burzyński who collaborated to lift this restriction.

更轻松地使用数组联合的方法

¥Easier Method Usage for Unions of Arrays

在之前的 TypeScript 版本中,在数组联合上调用方法可能会很麻烦。

¥In previous versions of TypeScript, calling a method on a union of arrays could end in pain.

ts
declare let array: string[] | number[];
array.filter(x => !!x);
// ~~~~~~ error!
// This expression is not callable.
// Each member of the union type '...' has signatures,
// but none of those signatures are compatible
// with each other.

在此示例中,TypeScript 会尝试检查 filter 的每个版本是否与 string[]number[] 兼容。由于缺乏连贯的策略,TypeScript 只能束手无策,只好放弃 “我无法让它工作”。

¥In this example, TypeScript would try to see if each version of filter is compatible across string[] and number[]. Without a coherent strategy, TypeScript threw its hands in the air and said “I can’t make it work”.

在 TypeScript 5.2 中,在放弃这些情况之前,数组的并集将被视为特殊情况。根据每个成员的元素类型构造一个新的数组类型,然后对其调用方法。

¥In TypeScript 5.2, before giving up in these cases, unions of arrays are treated as a special case. A new array type is constructed out of each member’s element type, and then the method is invoked on that.

以上面的例子为例,string[] | number[] 被转换为 (string | number)[](或 Array<string | number>),并且 filter 在该类型上被调用。有一点需要注意的是,filter 将生成 Array<string | number> 而不是 string[] | number[];但对于新生成的值来说,出现 “正在进行错误” 的风险较小。

¥Taking the above example, string[] | number[] is transformed into (string | number)[] (or Array<string | number>), and filter is invoked on that type. There is a slight caveat which is that filter will produce an Array<string | number> instead of a string[] | number[]; but for a freshly produced value there is less risk of something “going wrong”.

这意味着许多方法(例如 filterfindsomeeveryreduce)现在都可以在数组并集上调用,而之前它们无法调用。

¥This means lots of methods like filter, find, some, every, and reduce should all be invokable on unions of arrays in cases where they were not previously.

你可以 阅读有关实现拉取请求的更多详细信息

¥You can read up more details on the implementing pull request.

仅类型导入路径和 TypeScript 实现文件扩展名

¥Type-Only Import Paths with TypeScript Implementation File Extensions

TypeScript 现在允许在仅类型导入路径中包含声明和实现文件扩展名,无论是否启用 allowImportingTsExtensions

¥TypeScript now allows both declaration and implementation file extensions to be included in type-only import paths, regardless of whether allowImportingTsExtensions is enabled.

这意味着你现在可以编写使用 .ts.mts.cts.tsx 文件扩展名的 import type 语句。

¥This means that you can now write import type statements that use .ts, .mts, .cts, and .tsx file extensions.

ts
import type { JustAType } from "./justTypes.ts";
export function f(param: JustAType) {
// ...
}

这也意味着 import() 类型(可在 TypeScript 和 JavaScript 中通过 JSDoc 使用)可以使用这些文件扩展名。

¥It also means that import() types, which can be used in both TypeScript and JavaScript with JSDoc, can use those file extensions.

js
/**
* @param {import("./justTypes.ts").JustAType} param
*/
export function f(param) {
// ...
}

更多信息请见 查看此处的变更

¥For more information, see the change here.

对象成员的逗号补全

¥Comma Completions for Object Members

向对象添加新属性时,很容易忘记添加逗号。以前,如果你忘记了逗号并请求自动补全,TypeScript 会给出令人困惑的、不相关的补全结果。

¥It can be easy to forget to add a comma when adding a new property to an object. Previously, if you forgot a comma and requested auto-completion, TypeScript would confusingly give poor unrelated completion results.

TypeScript 5.2 现在可以优雅地在缺少逗号时提供对象成员补全。但是,为了避免出现语法错误,它还会自动插入缺失的逗号。

¥TypeScript 5.2 now gracefully provides object member completions when you’re missing a comma. But to just skip past hitting you with a syntax error, it will also auto-insert the missing comma.

Properties in an object literal are completed despite missing a comma after a prior property. When the property name is completed, the missing comma is automatically inserted.

更多信息请见 查看此处的实现

¥For more information, see the implementation here.

内联变量重构

¥Inline Variable Refactoring

TypeScript 5.2 现在进行了重构,可以将变量的内容内联到所有使用位置。

¥TypeScript 5.2 now has a refactoring to inline the contents of a variable to all usage sites.

A variable called 'path' initialized to a string, having both of its usages replaced

.

使用 “内联变量” 重构将消除该变量,并将所有变量的用法替换为其初始化器。请注意,这可能会导致初始化程序的副作用在不同时间运行,并且运行次数与变量的使用次数相同。

¥Using the “inline variable” refactoring will eliminate the variable and replace all the variable’s usages with its initializer. Note that this may cause that initializer’s side-effects to run at a different time, and as many times as the variable has been used.

详情请见 查看实现拉取请求

¥For more details, see the implementing pull request.

优化持续类型兼容性检查

¥Optimized Checks for Ongoing Type Compatibility

由于 TypeScript 是一个结构化类型系统,因此有时需要以成员方式比较类型;但是,递归类型在这里会带来一些问题。例如:

¥Because TypeScript is a structural type system, types occasionally need to be compared in a member-wise fashion; however, recursive types add some issues here. For example:

ts
interface A {
value: A;
other: string;
}
interface B {
value: B;
other: number;
}

当检查类型 A 是否与类型 B 兼容时,TypeScript 最终会分别检查 AB 中的 value 类型是否兼容。此时,类型系统需要停止进一步检查,并继续检查其他成员。为此,类型系统必须跟踪任何两种类型何时已经关联。

¥When checking whether the type A is compatible with the type B, TypeScript will end up checking whether the types of value in A and B are respectively compatible. At this point, the type system needs to stop checking any further and proceed to check other members. To do this, the type system has to track when any two types are already being related.

以前,TypeScript 已经保存了一个类型对堆栈,并通过迭代来确定这些类型是否相关。如果堆栈较浅,则不会出现问题;但是当堆栈不够浅时,呃,存在问题

¥Previously TypeScript already kept a stack of type pairs, and iterated through that to determine whether those types are being related. When this stack is shallow that’s not a problem; but when the stack isn’t shallow, that, uh, is a problem.

在 TypeScript 5.3 中,简单的 Set 有助于跟踪此信息。这将使用 drizzle 库的报告测试用例所花费的时间减少了 33% 以上!

¥In TypeScript 5.3, a simple Set helps track this information. This reduced the time spent on a reported test case that used the drizzle library by over 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'

点击此处了解更多关于此变更的信息

¥Read more on the change here.

重大变更和正确性修复

¥Breaking Changes and Correctness Fixes

TypeScript 力求避免不必要地引入断言;但是,有时我们必须进行更正和改进,以便更好地分析代码。

¥TypeScript strives not to unnecessarily introduce breaks; however, occasionally we must make corrections and improvements so that code can be better-analyzed.

lib.d.ts 变更

¥lib.d.ts Changes

为 DOM 生成的类型可能会对你的代码库产生影响。更多信息请见 查看 TypeScript 5.2 的 DOM 更新

¥Types generated for the DOM may have an impact on your codebase. For more information, see the DOM updates for TypeScript 5.2.

labeledElementDeclarations 可以包含 undefined 元素

¥labeledElementDeclarations May Hold undefined Elements

为了支持 支持混合使用带标签和不带标签的元素,TypeScript 的 API 略有更改。TupleTypelabeledElementDeclarations 属性可以在元素未标记的每个位置保存 undefined

¥In order to support a mixture of labeled and unlabeled elements, TypeScript’s API has changed slightly. The labeledElementDeclarations property of TupleType may hold undefined for at each position where an element is unlabeled.

diff
interface TupleType {
- labeledElementDeclarations?: readonly (NamedTupleMember | ParameterDeclaration)[];
+ labeledElementDeclarations?: readonly (NamedTupleMember | ParameterDeclaration | undefined)[];
}

modulemoduleResolution 必须在最近的 Node.js 设置下匹配

¥module and moduleResolution Must Match Under Recent Node.js settings

--module--moduleResolution 选项分别支持 node16nodenext 设置。这些实际上是 “现代 Node.js” 设置,应该用于任何近期的 Node.js 项目。我们发现,当这两个选项在是否使用 Node.js 相关设置方面不一致时,项目实际上配置错误。

¥The --module and --moduleResolution options each support a node16 and nodenext setting. These are effectively “modern Node.js” settings that should be used on any recent Node.js project. What we’ve found is that when these two options don’t agree on whether they are using Node.js-related settings, projects are effectively misconfigured.

在 TypeScript 5.2 中,当对 --module--moduleResolution 选项使用 node16nodenext 时,TypeScript 现在要求另一个选项具有类似的 Node.js 相关设置。如果设置出现分歧,你可能会收到类似以下错误消息:

¥In TypeScript 5.2, when using node16 or nodenext for either of the --module and --moduleResolution options, TypeScript now requires the other to have a similar Node.js-related setting. In cases where the settings diverge, you’ll likely get an error message like either

Option 'moduleResolution' must be set to 'NodeNext' (or left unspecified) when option 'module' is set to 'NodeNext'.

或者

¥or

Option 'module' must be set to 'Node16' when option 'moduleResolution' is set to 'Node16'.

例如,--module esnext --moduleResolution node16 将被拒绝 - 但你可能最好只使用 --module nodenext--module esnext --moduleResolution bundler

¥So for example --module esnext --moduleResolution node16 will be rejected — but you may be better off just using --module nodenext alone, or --module esnext --moduleResolution bundler.

更多信息请见 查看此处的变更

¥For more information, see the change here.

合并符号的一致性导出检查

¥Consistent Export Checking for Merged Symbols

当两个声明合并时,它们必须就是否同时导出达成一致。由于一个错误,TypeScript 遗漏了环境上下文中的特定情况,例如在声明文件或 declare module 块中。例如,在以下情况下,它不会触发错误,其中 replaceInFile 一次声明为导出函数,另一次声明为未导出的命名空间。

¥When two declarations merge, they must agree on whether they are both exported. Due to a bug, TypeScript missed specific cases in ambient contexts, like in declaration files or declare module blocks. For example, it would not issue an error on a case like the following, where replaceInFile is declared once as an exported function, and one as an un-exported namespace.

ts
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 的声明都需要在其修饰符中保持一致时,会触发错误,并会触发以下错误:

¥In an ambient module, adding an export { ... } or a similar construct like export default ... implicitly changes whether all declarations are automatically exported. TypeScript now recognizes these unfortunately confusing semantics more consistently, and issues an error on the fact that all declarations of replaceInFile need to agree in their modifiers, and will issue the following error:

Individual declarations in merged declaration 'replaceInFile' must be all exported or all local.

更多信息请见 查看此处的变更

¥For more information, see the change here.