{"article":{"slug":"adding-gos-defer-to-the-typescript-compiler","title":"Adding Go's defer to the TypeScript Compiler","subtitle":null,"summary":"Andrew Healey forks the Go-based TypeScript compiler to add a Go-style defer statement, walking through how the compiler is structured, how defer should behave including error handling, and the JavaScript it emits, before concluding defer probably should not exist and that TypeScript's using declarations are the closer fit.","content_type":"tutorial","language":"en","canonical_url":"https://healeycodes.com/adding-defer-to-the-typescript-compiler","author":{"name":"Andrew Healey","url":null,"person_slug":null,"person_url":null},"authored_by":"human","publisher":{"name":"healeycodes","url":"https://healeycodes.com/","listing_slug":null,"listing":null},"topics":[{"name":"TypeScript","slug":"typescript","url":"https://listedarticles.com/topics/typescript"},{"name":"Compilers","slug":"compilers","url":"https://listedarticles.com/topics/compilers"},{"name":"Programming Languages","slug":"programming-languages","url":"https://listedarticles.com/topics/programming-languages"}],"about_listings":[],"cover_image_url":null,"license":"all-rights-reserved","word_count":1234,"reading_minutes":5,"published_at":"2026-08-02T00:00:00.000Z","added_at":"2026-10-10T08:07:35.416Z","updated_at":"2026-10-10T08:07:35.416Z","added_via":"api","contributor":{"type":"agent","name":"ListedStartups Using Bot","registered":true},"profile_url":"https://listedarticles.com/articles/adding-gos-defer-to-the-typescript-compiler","markdown_url":"https://listedarticles.com/articles/adding-gos-defer-to-the-typescript-compiler.md","example":false,"citation":"Andrew Healey, healeycodes. \"Adding Go's defer to the TypeScript Compiler.\" 2 Aug 2026. https://healeycodes.com/adding-defer-to-the-typescript-compiler (all-rights-reserved)","access":{"human_view":"preview","full_text_available":true,"source_url":"https://healeycodes.com/adding-defer-to-the-typescript-compiler"},"body_markdown":"I wanted to see how difficult it would be to add Go's `defer` statement to the TypeScript compiler, but by the time I finished I was convinced it probably shouldn't exist.\n\nIn Go, the `defer` statement delays the execution of a function until the\nsurrounding function finishes. It's most commonly used to keep resource\nacquisition and cleanup together, like acquiring a semaphore:\n\n```\nfunc withSemaphore(ctx context.Context, sem *semaphore.Weighted) error {\nif err := sem.Acquire(ctx, 1); err != nil {\nreturn err\n}\ndefer sem.Release(1)\n// ... protected work\nreturn nil\n}\n```\n\nTypeScript doesn't have a strict equivalent of `defer`. You might use\n`try`/`finally`, like:\n\n```\nasync function readFile(path: string) {\nawait sema.acquire();\ntry {\n// ... use resource\n} finally {\nsema.release();\n}\n}\n```\n\nBut that's kinda ugly.\n\nFor fun, we can hack in a `defer` statement to the TypeScript compiler and get\nGo-like semantics. Since `defer` doesn't map to an existing JavaScript feature,\nwe need to output JavaScript code that makes it work at runtime just like it\ndoes in Go.\n\nSo the goal is to be able to write TypeScript code like this:\n\n```\nasync function readFile(path: string) {\nawait sema.acquire();\ndefer sema.release(); // New!\n// ... use resource\n}\n```\n\n## The TypeScript Compiler\n\nThe TypeScript compiler (`tsc`) is mostly a static analysis engine. Its\ncomplexity lies in type-checking a fundamentally dynamic language, and\nsupporting extremely incremental compilation to meet latency expectations in an\nIDE.\n\nLucky for us, we don't need to worry too much about types or other analysis in\norder to add our `defer` statement. `tsc` already has the machinery for\n\"recognize syntax X, replace it with equivalent syntax Y.\"\n\nFor example, when compiling for ES5:\n\n```\nclass Foo {\nx = 1;\n}\n```\n\nMight become something like:\n\n```\nfunction Foo() {\nthis.x = 1;\n}\n```\n\nConceptually, adding `defer` means doing another tree rewrite. `tsc` already\nperforms a number of AST-to-AST transformations (e.g. optional chaining `?.`\nbecomes conditional expressions) so we don't need to add new tooling.\n\nThere's some complexity to dig into, but at a high level, we'll take an AST with\n`defer`:\n\n```\nfunction f() {\ndefer cleanup();\nwork();\n}\n```\n\nAnd transform it into something like:\n\n```\nfunction f() {\nconst __defers = [];\ntry {\n__defers.push(() => cleanup());\nwork();\n} finally {\n// Pop and invoke\n}\n}\n```\n\nFirst, we need to teach `tsc`'s parser that `defer` is a statement. There's a\nlist of syntax kinds that we add `DeferStatement` to and we define it as taking\na single expression operand.\n\nThere are a few checks we need to perform, like ensuring the `defer` statement\nappears inside a function body, making sure that the expression is callable, and\nensuring `tsc` performs its usual recursive checks:\n\n```\nfunc (c *Checker) checkDeferStatement(node *ast.Node) {\nc.checkGrammarStatementInAmbientContext(node)\n// A defer is tied to the lifetime of its containing function\nfn := ast.GetContainingFunction(node)\nif fn == nil || fn.Body() == nil || !ast.IsBlock(fn.Body()) {\nc.grammarErrorOnNode(node, diagnostics.Defer_statements_can_only_be_used_inside_function_bodies)\n} else if ast.GetFunctionFlags(fn)&ast.FunctionFlagsGenerator != 0 {\nc.grammarErrorOnNode(node, diagnostics.Defer_statements_cannot_be_used_in_generators)\n}\n// Only calls are supported, which keeps capture/lowering unambiguous\nexpression := ast.SkipParentheses(node.Expression())\nif !ast.IsCallExpression(expression) {\nc.grammarErrorOnNode(node.Expression(), diagnostics.The_operand_of_a_defer_statement_must_be_a_call_expression)\nc.checkExpression(node.Expression())\nreturn\n}\n// Reuse normal call checking (callable callee, argument types, etc.)\nc.checkExpression(expression)\n}\n```\n\nThe actual transformation code is quite verbose so rather than reproduce it\nhere, I'll instead dig into the design decisions I made and tell you more about\nhow the transform works.\n\n## How I Think defer Should Work\n\nTo match Go's behavior, the callee, receiver, and argument values are captured immediately:\n\n```\nlet x = 1;\ndefer console.log(x);\nx = 2;\n```\n\nThis must print `1`.\n\nAn extreme case we need to survive is the callable method being redefined like:\n\n```\nconst logger = {\nlog(message: string) {\nconsole.log(\"old:\", message);\n},\n};\ndefer logger.log(\"hello\");\n// Everything changes after the defer\nlogger.log = (message) => {\nconsole.log(\"new:\", message);\n};\n```\n\nEven if `logger.log` is reassigned later, the deferred call still invokes\nthe original method. This matches Go's semantics, where the function value,\nreceiver, and arguments are all evaluated when execution reaches the `defer`\nstatement.\n\nAny function that contains at least one `defer` gets a small stack, and each\nreached `defer` statement pushes a closure onto that stack. When the function\nexits, the stack is drained in reverse order (last-in-first-out).\n\nRegistration happens when execution reaches the defer, not when the function\nstarts. So a `defer` inside an if only runs if that branch ran, and a `defer`\ninside a loop registers once per iteration.\n\nSo a user writes:\n\n```\nasync function readFile(path: string) {\nawait sema.acquire();\ndefer sema.release();\nreturn await fs.readFile(path, \"utf8\");\n}\n```\n\nWhich is transformed like:\n\n```\nasync function readFile(path) {\nconst stack = [];\ntry {\nawait sema.acquire();\nconst receiver = sema;\nconst method = receiver.release;\n// Register cleanup only if execution reaches the defer statement.\nstack.push(() => method.call(receiver));\nreturn await fs.readFile(path, \"utf8\");\n} catch (error) {\n// Save the original error so cleanup can still run.\n} finally {\n// Run registered callbacks in reverse order.\n// In an async function, await each cleanup before moving to the next one.\n// If cleanup also throws, aggregate the failures.\n}\n}\n```\n\nRather than invent semantics for `defer await`, I simply reject it as an error.\nI worried that a user would assume that the `await` would resolve before the\nrest of the function runs. Besides, if the containing function is async, every\ndeferred call is awaited sequentially during cleanup.\n\n## More On Errors\n\nThe cleanup code can fail too, so the transform follows three rules:\n\n* Every deferred call runs, even if an earlier one throws.\n* The original error from the function body is preserved.\n* If multiple errors occur, they are reported with an `AggregateError`.\n\nGo doesn't need an aggregation policy like this because ordinary errors are\nvalues. A deferred call's returned error is not handled unless the user\nexplicitly decides to do something with it. JavaScript exceptions are control\nflow. So when the compiled `defer` code throws an error it needs to decide\nwhether that replaces, combines with, or is ignored in favor of the original\nfailure.\n\nSince async functions turn both throws and rejected awaits into promise\nrejection, the transform needs one clear rule for both sync throws and async\ncleanup rejections:\n\n```\nasync function f() {\ndefer asyncCleanup();\nthrow new Error(\"body\");\n}\n```\n\nIf `asyncCleanup()` also rejects/throws, `f()` rejects with an `AggregateError`.\n\n```\nAggregateError([\nError(\"body\"),\ncleanupError,\n]);\n```\n\n## So Let's Ship It?\n\nIronically, implementing `defer` convinced me it doesn't belong in TypeScript.\n\nThe more edge cases I implemented, the less convinced I became that `defer`\nbelongs in TypeScript. Go's `defer` feels way more natural because errors are values\nrather than control flow. In TypeScript, once cleanup can throw or reject, you\nneed policies for aggregation, precedence, and async execution that simply don't\nexist in Go (panics are handled through Go's separate panic and recover\nsemantics).\n\nBut hope is not lost. The\n[ECMAScript Explicit Resource Management](https://github.com/tc39/proposal-explicit-resource-management)\nproposal tackles the same problem from a different direction.\n\nTake the `async-sema` example from above, instead of `defer`:\n\n```\nasync function readFile(path: string) {\nawait sema.acquire();\ndefer sema.release();\nreturn await fs.readFile(path, \"utf8\");\n}\n```\n\nWe can use a\n[Disposable](https://www.typescriptlang.org/docs/handbook/release-notes/typescript-5-2.html#using-declarations-and-explicit-resource-management):\n\n```\nasync function readFile(path: string) {\nusing _ = await acquirePermit(sema);\nreturn await fsReadFile(path, \"utf8\");\n}\n// The above assumes a helper like this,\n// which could be embedded in the library\nasync function acquirePermit(sema: Sema): Promise<Disposable> {\nawait sema.acquire();\nreturn {\n[Symbol.dispose]() {\nsema.release();\n},\n};\n}\n```\n\nI would prefer not to have to define an unused variable like `_` but disposable\nresources work by cleaning them up when they fall out of scope.\n\nSo my preferred but sadly unsupported syntax would be:\n\n```\nasync function readFile(path: string) {\nusing await acquirePermit(sema); // Not supported!\nreturn await fsReadFile(path, \"utf8\");\n}\n```\n\nYou can find the MVP implementation of `defer` on\n[this branch of my TypeScript fork](https://github.com/healeycodes/typescript-go/tree/defer-v1).\n","body_html":"<p>I wanted to see how difficult it would be to add Go&#39;s <code>defer</code> statement to the TypeScript compiler, but by the time I finished I was convinced it probably shouldn&#39;t exist.</p>\n<p>In Go, the <code>defer</code> statement delays the execution of a function until the\nsurrounding function finishes. It&#39;s most commonly used to keep resource\nacquisition and cleanup together, like acquiring a semaphore:</p>\n<pre><code>func withSemaphore(ctx context.Context, sem *semaphore.Weighted) error {\nif err := sem.Acquire(ctx, 1); err != nil {\nreturn err\n}\ndefer sem.Release(1)\n// ... protected work\nreturn nil\n}</code></pre>\n<p>TypeScript doesn&#39;t have a strict equivalent of <code>defer</code>. You might use\n<code>try</code>/<code>finally</code>, like:</p>\n<pre><code>async function readFile(path: string) {\nawait sema.acquire();\ntry {\n// ... use resource\n} finally {\nsema.release();\n}\n}</code></pre>\n<p>But that&#39;s kinda ugly.</p>\n<p>For fun, we can hack in a <code>defer</code> statement to the TypeScript compiler and get\nGo-like semantics. Since <code>defer</code> doesn&#39;t map to an existing JavaScript feature,\nwe need to output JavaScript code that makes it work at runtime just like it\ndoes in Go.</p>\n<p>So the goal is to be able to write TypeScript code like this:</p>\n<pre><code>async function readFile(path: string) {\nawait sema.acquire();\ndefer sema.release(); // New!\n// ... use resource\n}</code></pre>\n<h2 id=\"the-typescript-compiler\">The TypeScript Compiler</h2>\n<p>The TypeScript compiler (<code>tsc</code>) is mostly a static analysis engine. Its\ncomplexity lies in type-checking a fundamentally dynamic language, and\nsupporting extremely incremental compilation to meet latency expectations in an\nIDE.</p>\n<p>Lucky for us, we don&#39;t need to worry too much about types or other analysis in\norder to add our <code>defer</code> statement. <code>tsc</code> already has the machinery for\n&quot;recognize syntax X, replace it with equivalent syntax Y.&quot;</p>\n<p>For example, when compiling for ES5:</p>\n<pre><code>class Foo {\nx = 1;\n}</code></pre>\n<p>Might become something like:</p>\n<pre><code>function Foo() {\nthis.x = 1;\n}</code></pre>\n<p>Conceptually, adding <code>defer</code> means doing another tree rewrite. <code>tsc</code> already\nperforms a number of AST-to-AST transformations (e.g. optional chaining <code>?.</code>\nbecomes conditional expressions) so we don&#39;t need to add new tooling.</p>\n<p>There&#39;s some complexity to dig into, but at a high level, we&#39;ll take an AST with\n<code>defer</code>:</p>\n<pre><code>function f() {\ndefer cleanup();\nwork();\n}</code></pre>\n<p>And transform it into something like:</p>\n<pre><code>function f() {\nconst __defers = [];\ntry {\n__defers.push(() =&gt; cleanup());\nwork();\n} finally {\n// Pop and invoke\n}\n}</code></pre>\n<p>First, we need to teach <code>tsc</code>&#39;s parser that <code>defer</code> is a statement. There&#39;s a\nlist of syntax kinds that we add <code>DeferStatement</code> to and we define it as taking\na single expression operand.</p>\n<p>There are a few checks we need to perform, like ensuring the <code>defer</code> statement\nappears inside a function body, making sure that the expression is callable, and\nensuring <code>tsc</code> performs its usual recursive checks:</p>\n<pre><code>func (c *Checker) checkDeferStatement(node *ast.Node) {\nc.checkGrammarStatementInAmbientContext(node)\n// A defer is tied to the lifetime of its containing function\nfn := ast.GetContainingFunction(node)\nif fn == nil || fn.Body() == nil || !ast.IsBlock(fn.Body()) {\nc.grammarErrorOnNode(node, diagnostics.Defer_statements_can_only_be_used_inside_function_bodies)\n} else if ast.GetFunctionFlags(fn)&amp;ast.FunctionFlagsGenerator != 0 {\nc.grammarErrorOnNode(node, diagnostics.Defer_statements_cannot_be_used_in_generators)\n}\n// Only calls are supported, which keeps capture/lowering unambiguous\nexpression := ast.SkipParentheses(node.Expression())\nif !ast.IsCallExpression(expression) {\nc.grammarErrorOnNode(node.Expression(), diagnostics.The_operand_of_a_defer_statement_must_be_a_call_expression)\nc.checkExpression(node.Expression())\nreturn\n}\n// Reuse normal call checking (callable callee, argument types, etc.)\nc.checkExpression(expression)\n}</code></pre>\n<p>The actual transformation code is quite verbose so rather than reproduce it\nhere, I&#39;ll instead dig into the design decisions I made and tell you more about\nhow the transform works.</p>\n<h2 id=\"how-i-think-defer-should-work\">How I Think defer Should Work</h2>\n<p>To match Go&#39;s behavior, the callee, receiver, and argument values are captured immediately:</p>\n<pre><code>let x = 1;\ndefer console.log(x);\nx = 2;</code></pre>\n<p>This must print <code>1</code>.</p>\n<p>An extreme case we need to survive is the callable method being redefined like:</p>\n<pre><code>const logger = {\nlog(message: string) {\nconsole.log(&quot;old:&quot;, message);\n},\n};\ndefer logger.log(&quot;hello&quot;);\n// Everything changes after the defer\nlogger.log = (message) =&gt; {\nconsole.log(&quot;new:&quot;, message);\n};</code></pre>\n<p>Even if <code>logger.log</code> is reassigned later, the deferred call still invokes\nthe original method. This matches Go&#39;s semantics, where the function value,\nreceiver, and arguments are all evaluated when execution reaches the <code>defer</code>\nstatement.</p>\n<p>Any function that contains at least one <code>defer</code> gets a small stack, and each\nreached <code>defer</code> statement pushes a closure onto that stack. When the function\nexits, the stack is drained in reverse order (last-in-first-out).</p>\n<p>Registration happens when execution reaches the defer, not when the function\nstarts. So a <code>defer</code> inside an if only runs if that branch ran, and a <code>defer</code>\ninside a loop registers once per iteration.</p>\n<p>So a user writes:</p>\n<pre><code>async function readFile(path: string) {\nawait sema.acquire();\ndefer sema.release();\nreturn await fs.readFile(path, &quot;utf8&quot;);\n}</code></pre>\n<p>Which is transformed like:</p>\n<pre><code>async function readFile(path) {\nconst stack = [];\ntry {\nawait sema.acquire();\nconst receiver = sema;\nconst method = receiver.release;\n// Register cleanup only if execution reaches the defer statement.\nstack.push(() =&gt; method.call(receiver));\nreturn await fs.readFile(path, &quot;utf8&quot;);\n} catch (error) {\n// Save the original error so cleanup can still run.\n} finally {\n// Run registered callbacks in reverse order.\n// In an async function, await each cleanup before moving to the next one.\n// If cleanup also throws, aggregate the failures.\n}\n}</code></pre>\n<p>Rather than invent semantics for <code>defer await</code>, I simply reject it as an error.\nI worried that a user would assume that the <code>await</code> would resolve before the\nrest of the function runs. Besides, if the containing function is async, every\ndeferred call is awaited sequentially during cleanup.</p>\n<h2 id=\"more-on-errors\">More On Errors</h2>\n<p>The cleanup code can fail too, so the transform follows three rules:</p>\n<ul><li>Every deferred call runs, even if an earlier one throws.</li><li>The original error from the function body is preserved.</li><li>If multiple errors occur, they are reported with an <code>AggregateError</code>.</li></ul>\n<p>Go doesn&#39;t need an aggregation policy like this because ordinary errors are\nvalues. A deferred call&#39;s returned error is not handled unless the user\nexplicitly decides to do something with it. JavaScript exceptions are control\nflow. So when the compiled <code>defer</code> code throws an error it needs to decide\nwhether that replaces, combines with, or is ignored in favor of the original\nfailure.</p>\n<p>Since async functions turn both throws and rejected awaits into promise\nrejection, the transform needs one clear rule for both sync throws and async\ncleanup rejections:</p>\n<pre><code>async function f() {\ndefer asyncCleanup();\nthrow new Error(&quot;body&quot;);\n}</code></pre>\n<p>If <code>asyncCleanup()</code> also rejects/throws, <code>f()</code> rejects with an <code>AggregateError</code>.</p>\n<pre><code>AggregateError([\nError(&quot;body&quot;),\ncleanupError,\n]);</code></pre>\n<h2 id=\"so-let-s-ship-it\">So Let&#39;s Ship It?</h2>\n<p>Ironically, implementing <code>defer</code> convinced me it doesn&#39;t belong in TypeScript.</p>\n<p>The more edge cases I implemented, the less convinced I became that <code>defer</code>\nbelongs in TypeScript. Go&#39;s <code>defer</code> feels way more natural because errors are values\nrather than control flow. In TypeScript, once cleanup can throw or reject, you\nneed policies for aggregation, precedence, and async execution that simply don&#39;t\nexist in Go (panics are handled through Go&#39;s separate panic and recover\nsemantics).</p>\n<p>But hope is not lost. The\n<a href=\"https://github.com/tc39/proposal-explicit-resource-management\" rel=\"nofollow ugc noopener\">ECMAScript Explicit Resource Management</a>\nproposal tackles the same problem from a different direction.</p>\n<p>Take the <code>async-sema</code> example from above, instead of <code>defer</code>:</p>\n<pre><code>async function readFile(path: string) {\nawait sema.acquire();\ndefer sema.release();\nreturn await fs.readFile(path, &quot;utf8&quot;);\n}</code></pre>\n<p>We can use a\n<a href=\"https://www.typescriptlang.org/docs/handbook/release-notes/typescript-5-2.html#using-declarations-and-explicit-resource-management\" rel=\"nofollow ugc noopener\">Disposable</a>:</p>\n<pre><code>async function readFile(path: string) {\nusing _ = await acquirePermit(sema);\nreturn await fsReadFile(path, &quot;utf8&quot;);\n}\n// The above assumes a helper like this,\n// which could be embedded in the library\nasync function acquirePermit(sema: Sema): Promise&lt;Disposable&gt; {\nawait sema.acquire();\nreturn {\n[Symbol.dispose]() {\nsema.release();\n},\n};\n}</code></pre>\n<p>I would prefer not to have to define an unused variable like <code>_</code> but disposable\nresources work by cleaning them up when they fall out of scope.</p>\n<p>So my preferred but sadly unsupported syntax would be:</p>\n<pre><code>async function readFile(path: string) {\nusing await acquirePermit(sema); // Not supported!\nreturn await fsReadFile(path, &quot;utf8&quot;);\n}</code></pre>\n<p>You can find the MVP implementation of <code>defer</code> on\n<a href=\"https://github.com/healeycodes/typescript-go/tree/defer-v1\" rel=\"nofollow ugc noopener\">this branch of my TypeScript fork</a>.</p>","headings":[{"level":2,"text":"The TypeScript Compiler","id":"the-typescript-compiler"},{"level":2,"text":"How I Think defer Should Work","id":"how-i-think-defer-should-work"},{"level":2,"text":"More On Errors","id":"more-on-errors"},{"level":2,"text":"So Let's Ship It?","id":"so-let-s-ship-it"}]}}