Adding Go's Defer to the TypeScript Compiler

healeycodes1 pts0 comments

Adding Go's defer to the TypeScript Compiler — Andrew HealeyI 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.<br>In Go, the defer statement delays the execution of a function until the<br>surrounding function finishes. It's most commonly used to keep resource<br>acquisition and cleanup together, like acquiring a semaphore:<br>func withSemaphore(ctx context.Context, sem *semaphore.Weighted) error {<br>if err := sem.Acquire(ctx, 1); err != nil {<br>return err<br>defer sem.Release(1)

// ... protected work<br>return nil

TypeScript doesn't have a strict equivalent of defer. You might use<br>try/finally, like:<br>async function readFile(path: string) {<br>await sema.acquire();<br>try {<br>// ... use resource<br>} finally {<br>sema.release();

But that's kinda ugly.<br>For fun, we can hack in a defer statement to the TypeScript compiler and get<br>Go-like semantics. Since defer doesn't map to an existing JavaScript feature,<br>we need to output JavaScript code that makes it work at runtime just like it<br>does in Go.<br>So the goal is to be able to write TypeScript code like this:<br>async function readFile(path: string) {<br>await sema.acquire();<br>defer sema.release(); // New!

// ... use resource

The TypeScript Compiler<br>The TypeScript compiler (tsc) is mostly a static analysis engine. Its<br>complexity lies in type-checking a fundamentally dynamic language, and<br>supporting extremely incremental compilation to meet latency expectations in an<br>IDE.<br>Lucky for us, we don't need to worry too much about types or other analysis in<br>order to add our defer statement. tsc already has the machinery for<br>"recognize syntax X, replace it with equivalent syntax Y."<br>For example, when compiling for ES5:<br>class Foo {<br>x = 1;

Might become something like:<br>function Foo() {<br>this.x = 1;

Conceptually, adding defer means doing another tree rewrite. tsc already<br>performs a number of AST-to-AST transformations (e.g. optional chaining ?.<br>becomes conditional expressions) so we don't need to add new tooling.<br>There's some complexity to dig into, but at a high level, we'll take an AST with<br>defer:<br>function f() {<br>defer cleanup();<br>work();

And transform it into something like:<br>function f() {<br>const __defers = [];<br>try {<br>__defers.push(() => cleanup());<br>work();<br>} finally {<br>// Pop and invoke

First, we need to teach tsc's parser that defer is a statement. There's a<br>list of syntax kinds that we add DeferStatement to and we define it as taking<br>a single expression operand.<br>There are a few checks we need to perform, like ensuring the defer statement<br>appears inside a function body, making sure that the expression is callable, and<br>ensuring tsc performs its usual recursive checks:<br>func (c *Checker) checkDeferStatement(node *ast.Node) {<br>c.checkGrammarStatementInAmbientContext(node)

// A defer is tied to the lifetime of its containing function<br>fn := ast.GetContainingFunction(node)<br>if fn == nil || fn.Body() == nil || !ast.IsBlock(fn.Body()) {<br>c.grammarErrorOnNode(node, diagnostics.Defer_statements_can_only_be_used_inside_function_bodies)<br>} else if ast.GetFunctionFlags(fn)&ast.FunctionFlagsGenerator != 0 {<br>c.grammarErrorOnNode(node, diagnostics.Defer_statements_cannot_be_used_in_generators)

// Only calls are supported, which keeps capture/lowering unambiguous<br>expression := ast.SkipParentheses(node.Expression())<br>if !ast.IsCallExpression(expression) {<br>c.grammarErrorOnNode(node.Expression(), diagnostics.The_operand_of_a_defer_statement_must_be_a_call_expression)<br>c.checkExpression(node.Expression())<br>return

// Reuse normal call checking (callable callee, argument types, etc.)<br>c.checkExpression(expression)

The actual transformation code is quite verbose so rather than reproduce it<br>here, I'll instead dig into the design decisions I made and tell you more about<br>how the transform works.<br>How I Think defer Should Work<br>To match Go's behavior, the callee, receiver, and argument values are captured immediately:<br>let x = 1;<br>defer console.log(x);<br>x = 2;

It must print 1.<br>An extreme case we need to survive is the callable method being redefined like:<br>const logger = {<br>log(message: string) {<br>console.log("old:", message);<br>},<br>};

defer logger.log("hello");

// Everything changes after the defer<br>logger.log = (message) => {<br>console.log("new:", message);<br>};

Even if logger.log is reassigned later, the deferred call still invokes<br>the original method. This matches Go's semantics, where the function value,<br>receiver, and arguments are all evaluated when execution reaches the defer<br>statement.<br>Any function that contains at least one defer gets a small stack, and each<br>reached defer statement pushes a closure onto that stack. When the function<br>exits, the stack is drained in reverse order (last-in-first-out).<br>Registration happens when execution reaches the defer, not when the function<br>starts. So a defer inside an if only runs if that branch ran, and a defer<br>inside a loop registers once per iteration.<br>So a user writes:<br>async function...

defer function like node typescript statement

Related Articles