
2026/08/03 5:12
TypeScript コンパイラへの Go の defer 機能追加
RSS: https://news.ycombinator.com/rss
要約▶
Japanese Translation:
この文章は、Go の
defer 文を TypeScript に統合することは、アーキテクチャ上の不整合および意味論的な衝突のために根本的に不適切であると論じています。Go はセマフォや「エラー-as-値」といったパターンに依存する一方、TypeScript には厳密な同等物が欠如しており、開発者はぎこちない try/finally ブロックやクロージャによる回避策を採用せざるを得なくなっています。著者は、AST から AST への変換を利用して defer cleanup() などの構文をクロージャを用いたスタックベースのメカニズムに変換するカスタム defer を実験的にコンパイラにハッキングしたものの、実装には深い複雑性が露呈しました。特定の制約には、呼び出し元(callee)、受領者(receiver)、引数を直ちに捕捉すること;前の呼び出しが例外を投げてもすべての deferred 呼び出しが実行されることを保証すること(元のエラーを維持し、複数のエラーを AggregateError を通じて集約すること);defer await を拒否すること;関数体内では defer 文が呼び出し可能な式であるべきだが生成関数内ではそうでないことを検証することなどが挙げられます。これらのニュアンスがありながらプライベートな TypeScript フォークにおいて利用可能な MVP が存在するにもかかわらず、著者はこの機能を TypeScript に強制させることは実用的ではないと結論づけます。代わりに、ECMAScript の明示的なリソース管理(Explicit Resource Management)案のような公式の標準化努力を待つことを推奨しており、この提案は using 文と disposable オブジェクトを用いて、Go 方式のエラー処理を直接移植するのではなく、より清潔な解決策を提供します。本文
TypeScript コンパイラへの Go 風 defer
構文追加の実装検証
deferGo の
defer 構文を TypeScript コンパイラ(tsc)に追加する難易度を検証した結果、実装が完了した時点ですでに**「おそらく存在すべきではない」と確信しました**。
Go と TypeScript の defer
の比較
deferGo の挙動
Go では、
defer を使用して関数終了直前のコード実行を遅延させます。主にリソースの確保と解放を束ねる目的で使用されます(例:セマフォ)。
func withSemaphore(ctx context.Context, sem *semaphore.Weighted) error { if err := sem.Acquire(ctx, 1); err != nil { return err } defer sem.Release(1) // 終了時に実行 // ... プロテクトされた処理 return nil }
TypeScript の現状
TypeScript には
defer と同等の構文は存在しません。代わりに try/finally を使用することになります。
async function readFile(path: string) { await sema.acquire(); try { // ... リソースの利用 } finally { sema.release(); // 明示的に記述が必要 } }
このアプローチは冗長であり、コードが「綺麗」ではありません。
コンパイラ実装の可能性と課題
defer 構文を実装することは技術的に行えます。TypeScript コンパイラに Go に似た振る舞いを JavaScript コードとして出力させることが可能です。
目標のシンタックス
async function readFile(path: string) { await sema.acquire(); defer sema.release(); // 新しい構文 // ... use resource }
実装アプローチ:AST 変換
TypeScript コンパイラ(
tsc)は静的解析エンジンであり、動的な言語への型チェックや低レイテンシ IDE サポートを処理しています。defer を追加する場合、深い型分析ではなく、既存の「シンタックス X → シンタックス Y」の変換機能を利用すれば十分です。
入力 AST:
function f() { defer cleanup(); work(); }
出力 JavaScript:
function f() { const __defers = []; try { __defers.push(() => cleanup()); work(); } finally { // Pop して実行する } }
パーサーとチェックの実装ステップ
- シンタックス定義:
をリストに追加し、単一の式オペランドを持つ文として定義します。DeferStatement - コンテキストチェック: 以下の条件を確認します。
文が関数本体内部にあること。defer- オペランドが実行可能な呼び出し式(Call Expression)であること。
- ジェネレーター関数内では使用不可であることを保証します。
func (c *Checker) checkDeferStatement(node *ast.Node) { c.checkGrammarStatementInAmbientContext(node) // defer はそのを含む関数のライフサイクルと結びついている fn := ast.GetContainingFunction(node) if fn == nil || fn.Body() == nil || !ast.IsBlock(fn.Body()) { c.grammarErrorOnNode(node, diagnostics.Defer_statements_can_only_be_used_inside_function_bodies) } else if ast.GetFunctionFlags(fn) & ast.FunctionFlagsGenerator != 0 { c.grammarErrorOnNode(node, diagnostics.Defer_statements_cannot_be_used_in_generators) } // 呼び出しのみをサポートしている expression := ast.SkipParentheses(node.Expression()) if !ast.IsCallExpression(expression) { c.grammarErrorOnNode(node.Expression(), diagnostics.The_operand_of_a_defer_statement_must_be_a_call_expression) c.checkExpression(node.Expression()) return } // 通常の呼び出しチェックを再利用(実行可能な callee、引数の型など) c.checkExpression(expression) }
注意: 実際のトランスフォームコードは冗長が多いためここでは省略します。
defer
の動作メカニズム
deferGo の挙動を再現するためには、呼び出し側(this)、リセーバー、引数の値を即時にキャプチャする必要があります。
キャプチャの必要性
let x = 1; defer console.log(x); x = 2; // defer が実行されたら 1 を出力しなければならない
メソッド再定義への耐性
メソッドが後に再定義されても、初期の状態を維持します。
const logger = { log(message: string) { console.log("old:", message); }, }; defer logger.log("hello"); // 以降はすべて変化します logger.log = (message) => { console.log("new:", message); }; // deferred call は依然として元のメソッドを呼び出します。
スティックとスタック処理
を含む関数には小さなスタックが割り当てられます。defer- 各
文は到達時にクロージャをプッシュします。defer - 関数終了時、スタックは**逆順(LIFO)**で排出されます。
- 登録タイミング: 関数開始時ではなく、実行が
に到達した時に発生します。defer
コード変換例
入力:
async function readFile(path: string) { await sema.acquire(); defer sema.release(); return await fs.readFile(path, "utf8"); }
出力(内部実装):
async function readFile(path) { const stack = []; try { await sema.acquire(); const receiver = sema; const method = receiver.release; // クリーンアップは defer 文に到達した場合のみ登録される。 stack.push(() => method.call(receiver)); return await fs.readFile(path, "utf8"); } catch (error) { // クリーンアップが実行できるように、元のエラーを保存する。 } finally { // 登録されたコールバックを逆順で実行する。 // async 関数の場合、各クリーンアップ処理は次の処理に移る前に await される。 } }
制限事項:defer await
は非対応
defer await
の独自のセマンティクスを実装する代わりに、エラーとして拒否しています(誤推測を防ぐため)。defer await
関数内のクリーンアップは、順次async
されます。await
エラー処理の課題
TypeScript ではエラーが例外(制御フロー)のため、Go のように値を返すわけではありません。コンパイルされたコードでエラーが発生した際、以下の 3 つのルールに従います:
- 以前のエラーのスローまたはすべての
の実行は続行されます。deferred call - 関数本体の元のエラーは保持されます。
- 複数のエラー発生時は
で報告されます。AggregateError
例:
async function f() { defer asyncCleanup(); throw new Error("body"); } // asyncCleanup() も reject/throw すると、f() は AggregateError で reject される。 // AggregateError([ // Error("body"), // cleanupError // ]);
TypeScript に
defer を実装する過程で生じる多くのエッジケース(集約、優先順位、非同期ポリシーなど)は、Go のエラーセマンティクスには存在しません。Go の defer は自然だが、TypeScript には不向きであることが確認できました。
代替案:ECMAScript Explicit Resource Management
希望を失う必要はありません。同じ課題を異なるアプローチで解決する標準規格(ES2024+)があります。
実装例:using
ステートメント
usingasync function readFile(path: string) { using _ = await acquirePermit(sema); // リソース解放が明示的 return await fsReadFile(path, "utf8"); } // ヘルパー関数 async function acquirePermit(sema: Sema): Promise<Disposable> { await sema.acquire(); return { [Symbol.dispose]() { sema.release(); }, }; }
理想的な構文(現時点では未対応)
TypeScript フォークのブランチには
defer の MVP 実装がありますが、以下の理想構文はまだサポートされていません。
// サポートされていない! async function readFile(path: string) { using await acquirePermit(sema); return await fsReadFile(path, "utf8"); }