using 宣告與顯式資源管理
TypeScript 5.2 增加了對 ECMAScript 中即將推出的顯式資源管理 (Explicit Resource Management) 特性的支援。讓我們探索一下其背後的動機,並瞭解該特性為我們帶來了什麼。
建立物件後,通常需要進行某種“清理”工作。例如,你可能需要關閉網路連線、刪除臨時檔案或僅僅是釋放一些記憶體。
設想一個函式,它建立一個臨時檔案,對其進行各種讀寫操作,最後將其關閉並刪除。
tsimport * 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);}
這樣做沒問題,但如果我們由於某種原因需要提前退出函式呢?
tsexport 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 塊中來解決。
tsexport 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 的一等公民來支援。
首先,引入了一個新的內建 symbol,名為 Symbol.dispose。我們可以建立帶有以 Symbol.dispose 命名的方法的物件。為了方便起見,TypeScript 定義了一個名為 Disposable 的新全域性型別來描述這些物件。
tsclass 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);}}
稍後我們可以呼叫這些方法。
tsexport function doSomeWork() {const file = new TempFile(".some_temp_file");try {// ...}finally {file[Symbol.dispose]();}}
將清理邏輯移動到 TempFile 本身並不能給我們帶來太多好處;我們基本上只是把所有的清理工作從 finally 塊移到了一個方法中,這在以前一直都是可行的。但是,為該方法提供一個眾所周知的“名稱”意味著 JavaScript 可以基於它構建其他特性。
這就引出了該特性的第一個主角:using 宣告!using 是一個新關鍵字,它允許我們宣告新的固定繫結,類似於 const。關鍵區別在於,使用 using 宣告的變數會在作用域結束時呼叫它們的 Symbol.dispose 方法!
所以,我們完全可以將程式碼寫成這樣:
tsexport function doSomeWork() {using file = new TempFile(".some_temp_file");// use file...if (someCondition()) {// do some more work...return;}}
看吧——沒有 try/finally 塊!至少,我們沒看到。從功能上講,這正是 using 宣告為我們所做的,但我們不必去處理那些複雜的邏輯。
你可能熟悉 C# 中的 using 宣告、Python 中的 with 語句,或者 Java 中的 try-with-resource 宣告。它們都與 JavaScript 的新 using 關鍵字類似,提供了一種相似的顯式方式,在作用域結束時執行物件的“拆卸”工作。
using 宣告會在其所在作用域的最末端,或者在“提前返回”(如 return)或丟擲錯誤之前執行清理。它們還會像棧一樣,以後進先出的順序進行釋放。
tsfunction 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 丟擲了錯誤,那麼該異常也會被重新丟擲。
但是,如果清理前的邏輯和清理過程本身都丟擲了錯誤會怎樣?針對這種情況,引入了 SuppressedError 作為 Error 的一個新子型別。它具有一個 suppressed 屬性,儲存了最後丟擲的錯誤,以及一個 error 屬性,儲存了最近丟擲的錯誤。
tsclass 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); // SuppressedErrorconsole.log(e.message); // An error was suppressed during disposal.console.log(e.error.name); // ErrorAconsole.log(e.error.message); // Error from aconsole.log(e.suppressed.name); // ErrorBconsole.log(e.suppressed.message); // oops!}
你可能已經注意到我們在這些示例中使用了同步方法。然而,許多資源釋放涉及非同步操作,我們需要在繼續執行任何其他程式碼之前等待這些操作完成。
這就是為什麼還有一個新的 Symbol.asyncDispose,它引出了該特性的下一個主角 —— await using 宣告。它們類似於 using 宣告,但關鍵在於它們會查詢哪些釋放操作必須被 await。它們使用以 Symbol.asyncDispose 命名的方法,不過它們也可以操作任何具有 Symbol.dispose 的物件。為了方便,TypeScript 還引入了一個名為 AsyncDisposable 的全域性型別,用於描述任何具有非同步釋放方法的物件。
tsasync 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
如果你希望其他人始終如一地執行拆卸邏輯,根據 Disposable 和 AsyncDisposable 定義型別可以讓你的程式碼更容易協作。實際上,現有的許多型別都已經擁有 dispose() 或 close() 方法。例如,Visual Studio Code API 甚至定義了它們自己的 Disposable 介面。瀏覽器以及 Node.js、Deno 和 Bun 等執行時的 API 也可能會選擇為已具備清理方法(如檔案控制代碼、連線等)的物件使用 Symbol.dispose 和 Symbol.asyncDispose。
現在,這對於庫來說聽起來很棒,但對於你的應用場景可能顯得有點沉重。如果你正在進行大量的臨時清理工作,建立一個新型別可能會引入過多的抽象和關於最佳實踐的疑問。例如,再次以我們的 TempFile 為例。
tsclass 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() 方法,還是自己傳入控制代碼?我們應該為每一個可能的操作公開一個方法,還是直接將屬性公開?
這就引出了該特性的最後主角:DisposableStack 和 AsyncDisposableStack。這些物件對於處理一次性清理以及任意數量的清理都非常有用。DisposableStack 是一個物件,它擁有多種方法來跟蹤 Disposable 物件,並可以被賦予執行任意清理工作的函式。我們還可以將它們賦值給 using 變數,因為——注意——它們本身也是 Disposable!所以,這就是我們如何重寫最初的例子。
tsfunction 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 方法,如 use 和 adopt)應該在建立資源後立即呼叫。顧名思義,DisposableStack 會像棧一樣以後進先出的順序釋放它跟蹤的所有內容,因此在建立值之後立即 defer 有助於避免奇怪的依賴問題。AsyncDisposableStack 的工作方式類似,但可以跟蹤 async 函式和 AsyncDisposable,它本身也是一個 AsyncDisposable。
defer 方法在許多方面類似於 Go、Swift、Zig、Odin 等語言中的 defer 關鍵字,它們的慣例應該是相似的。
由於此特性非常新,大多數執行時將不會原生支援它。要使用它,你需要為以下內容提供執行時 polyfill:
Symbol.disposeSymbol.asyncDisposeDisposableStackAsyncDisposableStackSuppressedError
然而,如果你只對 using 和 await using 感興趣,你應該只需要對內建的 symbol 進行 polyfill。在大多數情況下,像下面這樣簡單的程式碼就可以工作:
tsSymbol.dispose ??= Symbol("Symbol.dispose");Symbol.asyncDispose ??= Symbol("Symbol.asyncDispose");
你還需要將編譯器的 target 設定為 es2022 或更低版本,並將 lib 設定配置為包含 "esnext" 或 "esnext.disposable"。
json{"compilerOptions": {"target": "es2022","lib": ["es2022", "esnext.disposable", "dom"]}}
有關此特性的更多資訊,請檢視 GitHub 上的工作進展!
裝飾器元資料 (Decorator Metadata)
TypeScript 5.2 實現了一個名為裝飾器元資料的 ECMAScript 新特性。
該特性的核心思想是讓裝飾器能夠輕鬆地在其所應用的類上建立和使用元資料。
每當使用裝飾器函式時,它們現在都可以訪問其上下文物件上的一個新 metadata 屬性。metadata 屬性僅僅持有一個簡單的物件。由於 JavaScript 允許我們隨意新增屬性,它可以作為一個由每個裝飾器更新的字典來使用。或者,由於類中每個被裝飾部分的 metadata 物件都是相同的,它可以用作 Map 的鍵。當類上的所有裝飾器執行完畢後,該物件可以透過 Symbol.metadata 在類上訪問。
tsinterface Context {name: string;metadata: Record<PropertyKey, unknown>;}function setMetadata(_target: any, context: Context) {context.metadata[context.name] = true;}class SomeClass {@setMetadatafoo = 123;@setMetadataaccessor bar = "hello!";@setMetadatabaz() { }}const ourMetadata = SomeClass[Symbol.metadata];console.log(JSON.stringify(ourMetadata));// { "bar": true, "baz": true, "foo": true }
這在許多不同的場景中非常有用。元資料可以附加到許多用途上,例如除錯、序列化或使用裝飾器執行依賴注入。由於元資料物件是按每個被裝飾的類建立的,框架既可以將它們私下用作 Map 或 WeakMap 的鍵,也可以根據需要附加屬性。
例如,假設我們想使用裝飾器來跟蹤在使用 JSON.stringify 時哪些屬性和訪問器是可序列化的,如下所示:
tsimport { serialize, jsonify } from "./serializer";class Person {firstName: string;lastName: string;@serializeage: number@serializeget fullName() {return `${this.firstName} ${this.lastName}`;}toJSON() {return jsonify(this)}constructor(firstName: string, lastName: string, age: number) {// ...}}
這裡,意圖是隻有 age 和 fullName 應該被序列化,因為它們被標記了 @serialize 裝飾器。我們為此定義了一個 toJSON 方法,但它只是呼叫了 jsonify,而 jsonify 使用了 @serialize 建立的元資料。
這是一個模組 ./serialize.ts 可能如何定義的示例:
tsconst 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。這可以保持資料私有,並且在這種情況下使用較少的型別斷言,但在其他方面是類似的。
tsconst 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。在大多數情況下,像下面這樣簡單的程式碼就可以工作:
tsSymbol.metadata ??= Symbol("Symbol.metadata");
你還需要將編譯器的 target 設定為 es2022 或更低版本,並將 lib 設定配置為包含 "esnext" 或 "esnext.decorators"。
json{"compilerOptions": {"target": "es2022","lib": ["es2022", "esnext.decorators", "dom"]}}
我們要感謝 Oleksandr Tarasiuk 為 TypeScript 5.2 貢獻了裝飾器元資料的實現!
具名與匿名元組元素
元組型別一直支援為每個元素使用可選的標籤或名稱。
tstype Pair<T> = [first: T, second: T];
這些標籤不會改變你可以對它們進行的操作——它們僅僅是為了幫助提高可讀性和輔助工具。
然而,TypeScript 之前有一條規則,即元組不能混合使用帶標籤和不帶標籤的元素。換句話說,要麼元組中的任何元素都不能有標籤,要麼所有元素都需要有標籤。
ts// ✅ fine - no labelstype Pair1<T> = [T, T];// ✅ fine - all fully labeledtype Pair2<T> = [first: T, second: T];// ❌ previously an errortype Pair3<T> = [first: T, T];// ~// Tuple members must all have names// or all not have names.
對於剩餘元素(rest elements),這可能會很煩人,因為我們被迫不得不加上 rest 或 tail 這樣的標籤。
ts// ❌ previously an errortype 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 會丟失標籤。
tstype 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 中,元組標籤的全有或全無限制已被取消。該語言現在還可以在展開到未標記的元組時保留標籤。
我們要向 Josh Goldberg 和 Mateusz Burzyński 表示感謝,他們合作取消了這一限制。
陣列聯合型別的更簡便方法使用
在以前的 TypeScript 版本中,對陣列的聯合型別呼叫方法可能會令人頭疼。
tsdeclare 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 只能攤手說“我無法讓它工作”。
在 TypeScript 5.2 中,在放棄這些情況之前,陣列的聯合型別被作為特殊情況處理。根據每個成員的元素型別構建一個新的陣列型別,然後在該型別上呼叫該方法。
以前面的例子為例,string[] | number[] 被轉換為 (string | number)[](或 Array<string | number>),然後在該型別上呼叫 filter。有一個細微的注意事項是,filter 會產生一個 Array<string | number> 而不是 string[] | number[];但對於一個新產生的值,出現“錯誤”的風險較小。
這意味著許多方法,如 filter、find、some、every 和 reduce,現在應該都可以在之前無法呼叫的陣列聯合型別上進行呼叫了。
你可以閱讀實現該功能的 pull request 瞭解更多詳情。
支援 TypeScript 實現副檔名的純型別匯入路徑
無論是否啟用了 allowImportingTsExtensions,TypeScript 現在都允許在純型別匯入路徑中包含宣告檔案和實現檔案的副檔名。
這意味著你現在可以編寫使用 .ts、.mts、.cts 和 .tsx 副檔名的 import type 語句。
tsimport type { JustAType } from "./justTypes.ts";export function f(param: JustAType) {// ...}
這也意味著可以在 TypeScript 和帶有 JSDoc 的 JavaScript 中使用的 import() 型別可以使用這些副檔名。
js/*** @param {import("./justTypes.ts").JustAType} param*/export function f(param) {// ...}
更多資訊,請參閱此處的更改。
物件成員的逗號補全
向物件新增新屬性時,很容易忘記新增逗號。以前,如果你忘記了逗號並請求自動補全,TypeScript 會令人困惑地給出一些不相關的補全結果。
TypeScript 5.2 現在可以在你缺少逗號時優雅地提供物件成員補全。而且為了跳過語法錯誤,它還會自動插入缺少的逗號。

更多資訊,請參閱此處的實現。
內聯變數重構
TypeScript 5.2 現在擁有一種將變數內容內聯到所有使用位置的重構功能。
.
使用“內聯變數”重構將消除該變數,並用其初始化表示式替換變數的所有使用處。注意,這可能會導致初始化表示式的副作用在不同時間執行,並且次數與變數被使用的次數相同。
更多詳情,請參閱實現該功能的 pull request。
針對持續型別相容性的最佳化檢查
由於 TypeScript 是一個結構化型別系統,有時需要按成員逐一比較型別;然而,遞迴型別在這裡增加了一些問題。例如:
tsinterface 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: oldTime (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 runsBenchmark 2: newTime (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 runsSummary'new' ran1.50 ± 0.05 times faster than 'old'
破壞性變更與正確性修復
TypeScript 努力不引入不必要的破壞性變更;然而,我們有時必須進行修正和改進,以便程式碼能更好地被分析。
lib.d.ts 變更
為 DOM 生成的型別可能會對你的程式碼庫產生影響。更多資訊,請參閱 TypeScript 5.2 的 DOM 更新。
labeledElementDeclarations 可能持有 undefined 元素
為了支援標籤元素與未標籤元素的混合,TypeScript 的 API 發生了細微的變化。TupleType 的 labeledElementDeclarations 屬性現在可能在每個未標記元素的位置持有 undefined。
diffinterface 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 被聲明瞭一次為匯出函式,一次為未匯出名稱空間。
tsdeclare 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.
更多資訊,請參閱此處的更改。