12k
All articles

JavaScriptがついにリソースの後片付けを覚えた

JavaScriptのExplicit Resource Managementでusing、await using、DisposableStack、SuppressedErrorを使い、ファイル、ロック、socket、接続を確実に解放する方法。

OpenReplay Team
OpenReplay Team
JavaScriptがついにリソースの後片付けを覚えた

JavaScriptの新機能「Explicit Resource Management(明示的リソース管理)」——Symbol.disposeSymbol.asyncDisposeを基盤とするusingおよびawait using宣言——は、ファイルハンドル、ソケット、ロック、データベース接続といった非メモリリソースの、スコープベースかつ決定論的なクリーンアップ機能を言語に提供します。これはガベージコレクションではありません。ガベージコレクションは非決定論的なスケジュールでメモリを回収するものであり、ファイルのクローズ、ロックの解放、イベントリスナーの登録解除を行うことはありません。usingがクリーンアップするのはまさにそれらのリソースであり、スコープが終了した瞬間に、予測可能な形で実行されます。

接続のクローズやロックの解放のためにtry/finallyブロックを手書きしてきたなら、この機能はそのボイラープレートをキーワード一つで置き換えます。本記事では、usingの動作原理、同期/非同期の分岐の仕組み、独自のdisposableの実装方法、DisposableStackを使った複数リソースの協調管理、未対応ライブラリへの後付け対応、そして新しいSuppressedErrorによるdisposalエラーの扱い方について解説します。(WeakRefFinalizationRegistryはメモリ管理に関連するGCツールであり、これとは全く別の仕組みです。)

重要なポイント

  • using宣言は、囲んでいるブロックが終了したときにオブジェクトの[Symbol.dispose]()メソッドを呼び出します。await using[Symbol.asyncDispose]()を呼び出してそれをawaitするため、Promiseを返すティアダウン処理がスコープを閉じる前に完了します。
  • 複数のリソースが同一スコープを共有する場合、それらは宣言の逆順でdisposeされます。これにより、依存するリソースが依存先よりも先にティアダウンされます。
  • Explicit Resource ManagementはTC39の完成済みプロポーザルであり、ES2026として標準化されています。Chrome 134(V8 13.8)、Firefox 141、Node.js 24(V8 13.6)でネイティブ対応済みです。Safariはまだ追いついておらず、Symbol.disposeシンボルはデスクトップ版Safari 26.4に到達しましたが、iOS版Safariではまだ未対応です。
  • DisposableStack.prototype.move()は、登録済みリソースを新しいスタックに移譲し、元のスタックをdispose済みとしてマークしますが、リソース自体はdisposeしません。これは、半構築状態のリソースをコンストラクタから安全に渡すためのパターンです。
  • コードがすでに例外をスローしている最中にdisposalもスローした場合、JavaScriptは両方をSuppressedErrorでラップするため、元の失敗もクリーンアップの失敗も失われることはありません。

try/finallyが解決しきれなかったクリーンアップ問題

開いたリソース——ファイルハンドル、ストリームリーダー、データベース接続、ロック——はすべてクローズする必要があり、それを保証する言語レベルのツールはこれまでtry/finallyしかありませんでした。機能はしますが、冗長であり、スケールしません。Web Streamsのリーダーを例に考えてみましょう。読み取り処理中にエラーが発生した際、エラーが伝播する前にreleaseLock()を呼び忘れると、ストリームはロックされたままになります。修正策は、読み取りループをtryブロックで囲み、finallyreader.releaseLock()を置いてロックが常に解放されるようにすることです。

リソースが一つであればこのパターンで十分です。しかし、相互依存する2〜3のリソースが絡むと、ネストされたfinallyブロックが生まれ、順序が重要になり、一つのクリーンアップ内でスローが発生すると他のクリーンアップがスキップされる可能性があります。「このリソースにはティアダウンが必要で、スコープの終了時に正しい順序でエラーパスでも実行する」という機械的な部分を、言語が代わりに処理してくれるようになりました。

usingキーワードの動作

usingキーワードは、ブロックスコープを持ち再代入不可能なバインディング(constに似ています)を宣言します。その値はnullundefined、または[Symbol.dispose]()メソッドを持つオブジェクトでなければなりません。変数がスコープを外れると、そのメソッドが自動的に実行されます。同期クリーンアップにはusingを使います。ティアダウンがPromiseを返す場合はawait usingを使います——これはブロックスコープの変数を非同期にdisposeするよう宣言するもので、値は[Symbol.asyncDispose]()または[Symbol.dispose]()メソッドを持つ必要があり、変数がスコープを外れるとそのメソッドが呼び出されてawaitされます。await usingは同期disposableも受け付けるため、どちらの種類かわからない場合はこちらを使えば安心です。

import fs from "node:fs/promises";

async function readConfig() {
  await using file = await fs.open("config.json", "r");
  const { buffer } = await file.read();
  return buffer.toString();
  // file[Symbol.asyncDispose]() がここで呼び出されてawaitされる(すべての終了パスで)
}

Node.jsのFileHandleオブジェクトはすでに非同期disposableプロトコルを実装しているため、手動のラッパーなしにこのコードが動作します。awaitが2箇所あることに注目してください。await fs.open()は取得をawaitしてPromiseをFileHandleにアンラップし、await usingは変数がスコープを外れたときにdisposalをawaitします。

複数のリソースが互いに依存している場合、順序のルールが特に重要になります。スコープ内に複数のusingまたはawait using宣言がある場合、すべてのdisposerは宣言の逆順で順次実行されます。宣言の種類に関わらず、finallyブロックと同様にすべての実行が保証されます。

await using db = await openConnection();      // 2番目にdispose
await using tx = await db.beginTransaction(); // 1番目にdispose
// txがdbより先にティアダウンされるため、
// トランザクションのファイナライズ時にはまだ接続が生きている

スコープについて補足します。usingはあらゆるブロック、関数ボディ、静的初期化ブロック、forヘッダー、そしてモジュールのトップレベルで有効です——ただしスクリプトのトップレベルでは無効です。スクリプトスコープは持続するためdisposerが発火しないからです。await usingのトップレベル使用はモジュール専用であり、トップレベルawaitに依存しています。仕様に準拠したルールはMDNのusingリファレンスに記載されています。

独自のdisposableを実装する

同期クリーンアップには[Symbol.dispose]()を、非同期クリーンアップには[Symbol.asyncDispose]()を実装するだけで、任意のオブジェクトがdisposable になります——基底クラスも登録ステップも不要です。ウェルノウンシンボルそのものがコントラクトです。オブジェクトが[Symbol.dispose]()メソッドを持てばdisposableですusing宣言は初期化子に対してそのシンボルを検索し、変数がスコープを外れたときに呼び出すメソッドを特定します。

以下は、本記事全体を通じて使用するデータベース接続ラッパーの例です。

class DatabaseConnection {
  #client;
  constructor(client) {
    this.#client = client;
  }
  query(sql, params) {
    return this.#client.query(sql, params);
  }
  async [Symbol.asyncDispose]() {
    await this.#client.end();
  }
}

async function getUser(pool, id) {
  await using db = new DatabaseConnection(await pool.acquire());
  return await db.query("SELECT * FROM users WHERE id = $1", [id]);
  // db[Symbol.asyncDispose]() がここで実行される——あらゆるパスでクライアントが解放される
}

同期のdisposerも同じ形で実装しますが、[Symbol.dispose]()を使います。同期disposerはPromiseを返すべきではありません。[Symbol.dispose]()が返すPromiseはawait usingによってawaitされないからです。非同期disposableを宣言するにはSymbol.asyncDisposeを使用してください。

DisposableStackによる複数リソースの協調管理

一つのオブジェクトが複数のリソースを所有している場合、またはループ内でリソースを取得している場合、DisposableStackAsyncDisposableStackを使えばそれらをまとめて管理し、逆順で一括disposeできます。どちらの構造も、リソースやdisposalアクションを追加するためのuse()adopt()defer()などのメソッドと、クリーンアップをトリガーするdispose()またはasyncDispose()メソッドを提供します。これら自体が[Symbol.dispose]()/[Symbol.asyncDispose]()を実装しているため、usingおよびawait usingと組み合わせて使えます。

3つの登録メソッドはそれぞれ異なるケースに対応しています。

メソッド登録対象使用場面
use(resource)disposeシンボルを持つリソースオブジェクト自体がdisposableの場合
adopt(value, onDispose)disposable非対応の値+クリーンアップコールバックclose/abortスタイルのメソッドを持つがシンボルを持たないリソース
defer(onDispose)リソースなしのスタンドアロンなクリーンアップコールバック任意のティアダウン(例:clearInterval)を実行したい場合
function startWorker() {
  using stack = new DisposableStack();
  const handle = setInterval(poll, 5000);
  stack.defer(() => clearInterval(handle));
  stack.adopt(openSocket(), (s) => s.close());
  // ブロック終了時に両方のクリーンアップが逆順で実行される
}

特筆すべき機能がmove()です。これはコンストラクションの安全性に関する実際の問題を解決します。関数スコープだけでは不十分なケース——クラスやusingのようにグループ化すべき複数のリソースをクラスフィールドやクロージャとして所有する場合——にDisposableStackAsyncDisposableStackが役立ちます。DisposableStack.prototype.move()は、登録済みのすべてのリソースを新しいスタックに移譲し、元のスタックをdispose済みとしてマークしますが、リソース自体はdisposeしません。これにより、リソースをローカルで構築し、構築が完全に成功した場合にのみ外部に渡すことができます。

function openResources() {
  using cleanup = new DisposableStack();
  const a = cleanup.use(openA());
  const b = cleanup.use(openB()); // ここでスローされると、`a`は自動的にdisposeされる
  const moved = cleanup.move();   // 成功時:所有権が移譲され、何もdisposeされない
  return moved;                   // 呼び出し元が両リソースのdisposalを所有する
}

openB()がスローした場合、ブロックが終了してcleanupaをdisposeするため、リークは発生しません。すべてが成功した場合、move()はリソースを無傷のまま呼び出し元に渡します。move()のセマンティクスはV8のExplicit Resource Managementの解説記事に記載されています。

disposalに未対応のライブラリへの後付け対応

usingを使うためにライブラリがSymbol.disposeを採用する必要はありません——Object.assignでシンボルを自分でアタッチすればよいのです。close()end()メソッドを持つがdisposeシンボルを持たないライブラリクライアントは、一行でdisposable化できます。Symbol.asyncDisposeメソッドをクライアントに割り当てることで、await using宣言やAsyncDisposableStack#use()で使えるようになります。後でプロトコルをネイティブ実装したバージョンにアップグレードした際には、シムを削除するよう促すエラーが発生します。

const client = await MongoClient.connect(url);
Object.assign(client, {
  async [Symbol.asyncDispose]() {
    await client.close();
  },
});

async function run() {
  await using db = client; // これでクリーンにdisposeされる
  // ...
}

Object.assignによるシムは、現在のエコシステムへの橋渡しです。ほとんどのサードパーティクライアントはまだdisposeシンボルを実装しておらず、このアプローチで今すぐ対応できます。

フロントエンドとNode.jsでの活用場面

クライアントサイドでリークしやすいリソースは、イベントリスナー、IntersectionObserver/ResizeObserverインスタンス、navigator.locks、ロックされたWeb Streams、IndexedDBトランザクションなど——いずれも明示的な解放ステップがあり、エラーパスで見落としやすいものです。これらをdisposableでラップすることで、ライフタイムをスコープに紐付けられます。V8チームの代表的な例はストリームリーダーです。[Symbol.dispose]()reader.releaseLock()を呼び出すオブジェクトにusing宣言を使えば、解放を覚えておく必要がなくなります。

孤立したオブザーバーや未解放のロックは、ローカルでの簡単なテストでは見つかりにくいリークです。数秒ごとにリロードするページでは問題になりませんが、長時間稼働するシングルページアプリのセッションでは蓄積し、緩やかなメモリ増加とインタラクションのジャンクを引き起こします——一度きりのリロードでは再現しないが、フルセッションリプレイで浮かび上がる類の、じわじわと進行する劣化です。usingでそれらのリソースをスコープに紐付けることが、このクラスのバグに対する構造的な解決策です。

サーバーサイドでは、fs/promisesのNode.js FileHandleオブジェクトや多くの接続タイプがすでに対応しており、上記のDatabaseConnectionのようなカスタムラッパーで残りをカバーできます。

SuppressedErrorによるdisposal中のエラー処理

Disposalは失敗することがあり、コードがすでに例外をスローしている最中に失敗することもあります——定義された動作がなければ、一方のエラーがもう一方を暗黙的に上書きしてしまいます。JavaScriptはこれをSuppressedErrorで解決します。disposal中にスローされたすべてのエラー(スコープ終了を引き起こした最初のエラーを含む)は一つのSuppressedErrorにまとめられます——それ以前の例外がsuppressedプロパティに、それ以降の例外がerrorプロパティに格納され、disposalが完了した後にスローされます。

つまり、ブロックがスローし、さらにdisposerもスローした場合、キャッチされるのは単一のSuppressedErrorであり、その.errorはdisposalの失敗、.suppressedは元の例外です。複数のdisposerがスローした場合はネストされるため、すべての失敗に到達できます。

try {
  using a = makeDisposableThatThrowsOnDispose("a");
  using b = makeDisposableThatThrowsOnDispose("b");
  throw new Error("body failed");
} catch (e) {
  // eはSuppressedError。bが先にdisposeされaが最後にdisposeされるため、
  // e.errorはaのdisposalエラー、e.suppressedには残り
  // (bのdisposalエラー、その後に元の"body failed")がネストされる
}

SuppressedErrorこそが、エラーの多いコードでもusingを安心して使えるようにする仕組みです。

現在のサポート状況:「近日公開」ではなく、すでに出荷済み

Explicit Resource ManagementはTC39の完成済みプロポーザルであり、ES2026の一部として標準化されています——あらゆる場所でトランスパイルが必要なStage 3の実験的機能ではありません。2026年半ば時点の状況は以下の通りです。

ランタイムステータス備考
Chrome / Edge / Opera出荷済みChromium 134(V8 13.8)以降でusing/await usingに対応。Symbol.disposeシンボル単体はChrome 125から存在
Node.js出荷済みNode.js 24(V8 13.6)でネイティブ対応。シンボルは18.18.0から対応
Firefox出荷済みFirefox 141以降(現在の安定版は152)
Safari部分対応Symbol.disposeシンボルはデスクトップ版Safari 26.4に到達。iOS版Safariはまだ未対応
TypeScript出荷済みバージョン5.2以降でusing構文に対応。現在の安定版は6.0

一点注意が必要です。Symbol.disposeシンボルが定義されていても、エンジンがusingをパースできるとは限りません。シンボルは宣言よりも先に実装される傾向があります——Nodeはv18系(18.18.0)でウェルノウンシンボルを公開しましたが、宣言構文のネイティブ対応はNode 24まで待つ必要がありました。ChromeもシンボルはChrome 125頃に実装しましたが、usingのパースはChrome 134まで対応しませんでした。つまり、古いエンジンではSymbol.disposeを参照できても、構文エラーや「not disposable」というTypeErrorが発生する可能性があります。Symbol.disposeのサポート表(上記リンク参照)は実際のusingサポートより先行しています。usingの完全なサポートはエンジンのバージョン問題であり、ポリフィルの問題ではありません。

Safariやそれよりもバージョンのターゲットにはトランスパイルを使用してください。TypeScriptではコンパイルターゲットをES2022以下に設定し、lib"esnext"または"esnext.disposable"を含めるよう設定する必要があります(TypeScriptはバージョン5.2から構文をサポート)。グローバルのポリフィルにはcore-jsまたはdisposablestackパッケージを使用できます。

手書きで書き続けてきた機械的でエラーを起こしやすいクリーンアップコードには、今や言語プリミティブが用意されています。ティアダウンが必要なものに[Symbol.dispose]または[Symbol.asyncDispose]を追加し、usingまたはawait usingで宣言すれば、ランタイムがすべての終了パスで正しい順序でdisposalを保証します。まずは最もクローズを忘れがちなリソース——接続、ロック、リーダー——をラップすることから始め、あとはスコープに任せましょう。

よくある質問

usingとawait usingの違いは何ですか?

using宣言はブロック終了時にオブジェクトの同期Symbol.disposeメソッドを呼び出します。一方、await usingはSymbol.asyncDisposeを呼び出してそれをawaitするため、Promiseを返すティアダウン処理がスコープを閉じる前に完了します。クリーンアップが同期的な場合はusing、ティアダウンがPromiseを返す場合はawait usingを使います。await usingは同期disposableも受け付けるため、オブジェクトがどちらのdisposerを持つか不明な場合はawait usingを使えば安心です。

Symbol.disposeが存在するのにNode 18でusingが「not disposable」エラーをスローするのはなぜですか?

Symbol.disposeシンボルが定義されていても、エンジンがusing宣言をパースできるとは限りません。NodeはバージョンのSymbol.disposeおよびSymbol.asyncDisposeウェルノウンシンボルをバージョン18.18.0から公開していましたが、usingおよびawait using宣言構文のネイティブ対応はV8 13.6を搭載するNode 24からです。古いNodeではシンボルを参照できても、組み込みハンドルで構文エラーやTypeErrorが発生する可能性があります。usingの完全なサポートはエンジンのバージョン問題であり、ポリフィルの問題ではありません。

Explicit Resource ManagementはガベージコレクションをReplaceしますか?

いいえ。ガベージコレクションは非決定論的なスケジュールでメモリを回収するものであり、ファイルのクローズ、ロックの解放、イベントリスナーの登録解除を行うことはありません。Explicit Resource Managementはそれらの非メモリリソースを決定論的に処理し、スコープが終了した瞬間にクリーンアップを実行します。両者は異なる問題に対処するものです。WeakRefとFinalizationRegistryはメモリに関連するGCツールであり、usingとawait usingはファイルハンドル、ソケット、ロック、接続のスコープベースのティアダウンを提供します。

disposeメソッドを持たないライブラリでusingキーワードを使うにはどうすればよいですか?

Object.assignでシンボルを自分でアタッチします。closeまたはendメソッドを持つがdisposeシンボルを持たないクライアントは、既存のcloseメソッドを呼び出す非同期Symbol.asyncDisposeメソッドを割り当てることで、一行でdisposable化できます。その後、await usingでオブジェクトを宣言したり、AsyncDisposableStackのuseメソッドで登録したりできます。後でプロトコルをネイティブ実装したライブラリバージョンにアップグレードした際には、再代入によってシムを削除するよう促すエラーが発生します。

Open-source session replay

Complete picture for complete understanding

Capture every clue your frontend is leaving so you can instantly get to the root cause of any issue with OpenReplay — the open-source session replay tool for developers. Self-host it in minutes, and have complete control over your customer data.

Star on GitHub12k

We use cookies to improve your experience. By using our site, you accept cookies.