TC39JavaScriptTypeScriptNode.jsResource Management

using/await using 實戰:Explicit Resource Management 導入邊界

整理 Explicit Resource Management(Stage 3)在 TypeScript 5.2+ 與 Node.js 的導入條件,說清 using/await using、esnext.disposable 與 runtime 邊界。

· 6 分鐘閱讀

這篇會帶你看懂 using / await using 在 2026 年能做什麼、不能做什麼,以及如何用可驗證方式導入到前端與 Node 專案。


前言

try...finally 是 JavaScript 團隊熟悉到不能再熟悉的 cleanup 模式。問題不在「你不知道要 cleanup」,而在真實專案裡它很容易變成散落在多層流程中的 maintenance burden:早退、拋錯、巢狀資源、測試 teardown 都會讓釋放邏輯變得脆弱。

TC39 的 Explicit Resource Management(ERM)就是針對這個痛點提出的語言級方案,用 using / await using 明確綁定資源生命週期,讓 cleanup 在離開 scope 時自動執行。

但這裡要先校正一件最重要的事:截至 2026-04-01,ERM 在 proposal repo 仍標示 Stage 3,不是 Stage 4,也不等於所有 runtime 都已全面原生可用。這篇的重點就是把「語法支援」和「runtime 可落地」拆清楚。

這個提案到底在解什麼問題

ERM 的核心不是取代 try...finally,而是把資源釋放從「慣例」變成「語法級生命週期」。

先看傳統寫法:

import { open } from "node:fs/promises";

export async function readConfig(path: string) {
  const file = await open(path, "r");
  try {
    return await file.readFile({ encoding: "utf8" });
  } finally {
    await file.close();
  }
}

邏輯本身沒問題,但當你同時管理 2~3 個資源,或中間再包一層 helper,很容易出現「哪個 finally 負責哪個資源」的閱讀成本。

ERM 做的事很單純:

  1. 宣告時註記資源生命週期(using / await using)。
  2. 離開目前 scope 時自動呼叫對應釋放方法。
  3. 多個資源依宣告反向順序釋放(LIFO)。

這是一種語意明確化,不是魔法。它不會替既有 close() API 自動套上 protocol,也不會幫你修正不正確的資源設計。

usingawait using 真正做了什麼

using: 同步釋放(Symbol.dispose

class ReaderLockResource implements Disposable {
  constructor(private readonly reader: ReadableStreamDefaultReader<Uint8Array>) {}

  [Symbol.dispose]() {
    // releaseLock() 是同步 API,適合用 using
    this.reader.releaseLock();
  }
}

function consumeOnce(stream: ReadableStream<Uint8Array>) {
  const reader = stream.getReader();
  using lock = new ReaderLockResource(reader);

  // 中途 throw 也會在離開 scope 時 release lock
  return reader.read();
}

using 需要的是 [Symbol.dispose](),不是 .dispose() 字串方法。

await using: 非同步釋放(Symbol.asyncDispose

import { open } from "node:fs/promises";

export async function firstLine(path: string) {
  // 第一個 await:等待 open() 拿到 FileHandle
  // await using:離開 scope 時 await file[Symbol.asyncDispose]()
  await using file = await open(path, "r");

  const text = await file.readFile({ encoding: "utf8" });
  return text.split("\n")[0] ?? "";
}

await using 跟一般 await 一樣有語法上下文限制:只能在 async function 內,或 module top-level。

如果資源不符合 protocol,會怎樣?

function demoTypeError() {
  // 這個物件沒有 [Symbol.dispose]
  using bad = { close() {} } as { close(): void };
  // 會在宣告點丟 TypeError(runtime)
  return bad;
}

using / await using 是 opt-in。只有實作對應 symbol 的物件,才是可管理資源。

TypeScript 與 runtime 支援:你要同時過兩關

很多團隊在這裡踩坑,是因為把「TypeScript 可編譯」誤認為「runtime 已可跑」。實際上是兩個獨立層次。

關卡 1:TypeScript 編譯器層

TypeScript 5.2+ 提供語法與型別支援,但你需要正確設定 targetlib

{
  "compilerOptions": {
    "target": "es2022",
    "module": "esnext",
    "lib": ["es2022", "esnext.disposable", "dom"],
    "strict": true
  }
}

如果你的 lib 沒含 esnext.disposable(或整包 esnext),Disposable / AsyncDisposable 型別與相關 symbol 會出現型別缺口。

關卡 2:runtime 層

TypeScript 5.2 release notes 也明確提醒:這個特性在很多 runtime 上不一定完整原生,必要時要補 polyfill(至少 Symbol.disposeSymbol.asyncDispose)。

// 最低限度 symbol polyfill(需在程式進入點最先執行)
Symbol.dispose ??= Symbol("Symbol.dispose");
Symbol.asyncDispose ??= Symbol("Symbol.asyncDispose");

若你還要用 DisposableStack / AsyncDisposableStack / SuppressedError,就不是只補 symbol 這麼簡單,通常要用完整 polyfill 套件或 runtime 原生支援。

Node.js 情境要特別注意版本邊界

fs/promisesFileHandle[Symbol.asyncDispose]() 為例,Node 文件可查到:

  1. v20.4.0 / v18.18.0 已加入
  2. v24.2.0 標示為不再 experimental

這代表你在 Node 專案裡用 await using file = await open(...) 時,應該明確寫進團隊的最低 Node 版本政策,而不是只在本機「剛好能跑」。

三個可驗證範例(前端工程情境)

範例 1:ReadableStream reader lock(同步 cleanup)

class StreamReaderResource implements Disposable {
  constructor(private readonly reader: ReadableStreamDefaultReader<Uint8Array>) {}

  [Symbol.dispose]() {
    this.reader.releaseLock();
  }
}

export async function readChunk(stream: ReadableStream<Uint8Array>) {
  const reader = stream.getReader();
  using resource = new StreamReaderResource(reader);

  const { value, done } = await reader.read();
  if (done || !value) return new Uint8Array();
  return value;
}

重點是 releaseLock() 本身是同步行為,所以用 using(不是 await using)較合理。

範例 2:Node 臨時檔案流程(非同步 cleanup)

import { mkdtemp, open, rm } from "node:fs/promises";
import { join } from "node:path";
import { tmpdir } from "node:os";

class TempDirResource implements AsyncDisposable {
  constructor(private readonly dirPath: string) {}

  async [Symbol.asyncDispose]() {
    await rm(this.dirPath, { recursive: true, force: true });
  }
}

export async function writeTempJson(payload: unknown) {
  const dir = await mkdtemp(join(tmpdir(), "erm-demo-"));
  await using tempDir = new TempDirResource(dir);

  const filePath = join(dir, "payload.json");
  await using file = await open(filePath, "w");

  await file.writeFile(JSON.stringify(payload), "utf8");
  return filePath;
}

這個例子同時示範兩個 await using,離開 scope 時會依 LIFO 順序釋放。

範例 3:測試中的可預期 cleanup(自訂 resource)

class MockServerResource implements AsyncDisposable {
  private closed = false;

  constructor(private readonly server: { close: () => Promise<void> }) {}

  async [Symbol.asyncDispose]() {
    if (this.closed) return;
    this.closed = true;
    await this.server.close();
  }
}

export async function runCase(createServer: () => Promise<{ close: () => Promise<void> }>) {
  await using mock = new MockServerResource(await createServer());

  // 執行測試流程
  // 即使 throw,server 仍會被關閉
}

這類模式在 integration test 很有價值,因為 cleanup 一致性比語法新奇更重要。

什麼情況值得用,什麼情況先不要用

值得用

  1. 你有明確「取得-釋放」生命週期的資源(file handle、reader lock、測試 server、DB connection wrapper)。
  2. 你已經在 TypeScript 層建立可驗證 contract(Disposable / AsyncDisposable)。
  3. 你能控管 runtime 版本或 polyfill 初始化順序。

先不要急著全專案改

  1. 既有 API 只有 .close() / .dispose(),但沒有 symbol protocol,短期內也不打算包裝。
  2. 執行環境過多(多 Node 版本、邊緣 runtime、舊瀏覽器)且目前沒統一 compat policy。
  3. 團隊連 try...finally 基本 discipline 都還沒建立,直接改語法只會把問題搬家。

務實做法通常是「先在高風險資源點導入」,例如測試資源與 file IO,再逐步推到其他模組。

常見問題 / 注意事項

1. using 是不是比 try...finally 更安全?

在「cleanup 一致執行」這件事上,using 通常更不容易漏;但它不是萬能。資源本身若沒正確實作 [Symbol.dispose] / [Symbol.asyncDispose],一樣會失敗。

2. await using 可以在一般函式用嗎?

不行。它需要可使用 await 的語法環境,也就是 async function 或 module top-level。

3. 我已經有 .close(),一定要改整個 API 嗎?

不一定。你可以先加一層小 wrapper,把 .close() 映射到 [Symbol.asyncDispose](),讓呼叫端先受益,再評估是否把 symbol protocol 納入正式 API。

4. DisposableStack 一定要用嗎?

不一定。單一資源用 using 就夠。DisposableStack / AsyncDisposableStack 比較適合動態收集多個資源且需要集中釋放的場景。

總結

Explicit Resource Management 在 2026-04-01 的正確定位是:提案仍在 Stage 3,但已足夠讓團隊在可控範圍做實務試點。你真正要管理的不是語法,而是三件事:

  1. 型別層是否明確(esnext.disposableDisposable contract)
  2. runtime 層是否可保證(版本或 polyfill)
  3. 團隊層是否有導入邊界(先小範圍、高價值場景)

把這三件事講清楚,using 才會是降低錯誤率的工程工具,而不是新語法展示。

參考資料

  • TC39 proposal repo(Explicit Resource Management,Stage 3)
  • TypeScript 5.2 release notes(using / await usingesnext.disposable、polyfill 注意事項)
  • MDN JavaScript resource management / using / await using
  • Node.js fs 文件(FileHandle[Symbol.asyncDispose]() 版本歷程)