有人把 Go 的 defer 塞進 typescript-go,作者做完卻說不該上線
healeycodes.com(Andrew Healey) · 2026 年 8 月
TypeScript 想把「取得資源、馬上登記釋放」寫成一行 defer sema.release();,取代今天的 try/finally 巢狀寫法。Andrew Healey 在 typescript-go(Go 版 tsc)上做出了可運作的 MVP,並公開在他的 fork 分支 defer-v1。
有趣的是,他的結論是 defer 不該進 TypeScript,真正該用的是 TC39 的明確資源管理提案。這篇文章等於一份「為什麼這條路走不通」的實作紀錄。
原本的問題
在 TypeScript 裡,凡是要成對處理的資源(鎖、semaphore、檔案),都得把後續邏輯包進 try,再於 finally 釋放。取得與釋放被拉開,中間的程式碼越長,越容易漏掉或寫錯順序。
| 現在 | defer 版 | |
|---|---|---|
| 寫法 | try { ... } finally { sema.release(); } | defer sema.release(); |
| 釋放位置 | 函式尾端 | 緊接在取得之後 |
作者選 tsc 當實驗場,是因為它雖以靜態分析為主,卻早就有語法對語法的改寫機制,例如把 ?. 降級成條件運算式、為 ES5 降級 class field。所以 defer 只需要新增一個 AST 節點與一個 transform。
核心改動
解析與檢查:parser 新增 DeferStatement 語法種類,運算元只有一個運算式。checkDeferStatement 要求它必須位於有區塊本體的函式內、不能在 generator 內,且運算元(去掉括號後)必須是呼叫運算式,否則報錯。
降級方式:每個函式建立一個 closure 堆疊,defer 執行時 push,最後在 finally 反向清空。輸出骨架大致如下(註解已精簡):
const stack = [];
try {
await sema.acquire();
const receiver = sema;
const method = receiver.release;
stack.push(() => method.call(receiver));
// ... 函式本體
} catch (error) { /* 保存原始錯誤 */ }
finally { /* 反向執行,async 內逐一 await */ }語意上有三個決定:呼叫目標、receiver 與參數在執行到 defer 時就先求值,之後改了變數或重新指派 logger.log 都不影響;defer 只有執行流經過才登記,放在迴圈內就每圈登記一次;清理順序是 LIFO。作者也刻意拒絕 defer await,避免使用者誤以為 await 會在函式其餘部分之前完成。
錯誤處理才是卡關處
Go 的 deferred 呼叫回傳的錯誤只是普通值,呼叫者可以忽略。但 JavaScript 的例外是控制流,降級後必須回答:本體丟錯、清理又丟錯,要保留哪一個?
- 每個 deferred 呼叫都要執行,即使前一個丟出例外。
- 本體的原始錯誤要保留。
- 多個失敗以
AggregateError回報,例如本體丟錯且asyncCleanup()也 reject 時,f()會 reject 成包含兩者的AggregateError。
作者認為這套優先順序與聚合規則是語言層級的政策負擔,屬於設計上的壞味道。文章未提供任何效能數字或基準測試,也沒有說明 transform 的完整程式碼(他說太冗長而省略)。
影響範圍
這不是一個會讓你的 build 壞掉的變更,目前也不是任何 TypeScript 發行版的功能,只是實驗分支。文章未提到有對應的 TypeScript 官方 PR。對一般專案的實際意義在於替代方案:
- 用 semaphore、lock、檔案 handle 且寫了大量
try/finally的程式碼,可改用 TypeScript 5.2 起支援的using宣告,由 TC39 Explicit Resource Management 提案定義。 - 要套用的資源物件需自行提供可被
using釋放的包裝,作者範例是acquirePermit(sema)。
async function readFile(path: string) {
using _ = await acquirePermit(sema);
return await fsReadFile(path, "utf8");
}作者說他更想要 using await acquirePermit(sema); 這種寫法,但該語法目前不被支援。另外,MVP 的限制包括:運算元只能是呼叫運算式、不可用於 generator、deferred 呼叫的回傳值未處理。
如果你正在寫自己的語法降級或 codemod,這篇的價值在於它具體列出「eager capture、LIFO、錯誤聚合」三個必須先決定的語意,而不是 Go 那種單純的 defer 語法糖。
原始來源:Adding Go's defer to the TypeScript Compiler、defer-v1 分支、TC39 Explicit Resource Management