Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions components/code.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@ require("prismjs/components/prism-lua")
require("prismjs/components/prism-lisp")
require("../lib/prism-forth")
require("../lib/prism-kernel")
require("../lib/prism-ts-with-defer")
require("../lib/prism-ts-with-using")
// --

import Highlight, { defaultProps, Language } from "prism-react-renderer";
Expand Down
6 changes: 6 additions & 0 deletions lib/prism-ts-with-defer.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
(function (Prism) {
const tsWithDefer = Prism.languages.extend("typescript", {});
tsWithDefer.keyword.unshift(/\bdefer\b/);

Prism.languages["ts-with-defer"] = tsWithDefer;
})(Prism);
6 changes: 6 additions & 0 deletions lib/prism-ts-with-using.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
(function (Prism) {
const tsWithUsing = Prism.languages.extend("typescript", {});
tsWithUsing.keyword.unshift(/\busing\b/);

Prism.languages["ts-with-using"] = tsWithUsing;
})(Prism);
11 changes: 7 additions & 4 deletions pages/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -53,14 +53,14 @@ export default function Home({ allPostsData, description, words }) {
News <a href="https://hn.algolia.com/?dateRange=all&page=0&prefix=false&query=healeycodes.com%20-queuedle&sort=byPopularity&type=story">23 times</a>.
</p>
<p>
I've worked at Vercel since 2021, mostly on the distributed build pipeline that runs untrusted customer code, as well as on the underlying ephemeral compute platform.
I've worked at Vercel since 2021, mostly on the distributed build pipeline that runs untrusted customer code, as well as on the underlying platform that powers all of Vercel's compute products.
</p>
{/* Maybe link to latest? */}
{/* <p>
My latest article is <Link href={`/${allPostsData[0].id}`}>{allPostsData[0].title}</Link>.
</p> */}
<p>
I enjoy understanding how things work and making them faster. Like how SIMD can make some programs <Link href="/counting-words-at-simd-speed">orders of magnitude quicker</Link>, or how a compiler can <Link href="/a-tiny-compiler-for-data-parallel-kernels">rewrite kernel loops for explicit data parallelism</Link>.
I enjoy understanding how things work and making them faster. Like how SIMD can make some programs <Link href="/counting-words-at-simd-speed">orders of magnitude quicker</Link>.
</p>
<p>
I wrote <Link href="/maybe-the-fastest-disk-usage-program-on-macos">one of the fastest disk-usage programs on macOS</Link> by
Expand All @@ -69,14 +69,17 @@ export default function Home({ allPostsData, description, words }) {
also showed how to beat the performance of <code>grep</code> by just <Link href="/beating-grep-with-go">using goroutines</Link>.
</p>
<p>
I like learning by building things from scratch; like a <Link href="/building-a-runtime-with-quickjs">JavaScript runtime</Link>, a <Link href="/building-a-shell">tiny shell</Link>, and <Link href="/a-fair-cancelable-semaphore-in-go">a fair, and cancelable semaphore in Go</Link>.
I like learning by building things from scratch; like a <Link href="/building-a-runtime-with-quickjs">JavaScript runtime</Link>, a <Link href="/building-a-shell">tiny shell</Link>, and <Link href="/a-fair-cancelable-semaphore-in-go">a fair, and cancelable semaphore in Go</Link>.
</p>
<p>
My <Link href="/installing-npm-packages-very-quickly">experimental package manager</Link> uses simple concurrency patterns to be faster than every package manager aside from Bun (mine is 11% slower) when cold-installing from a lockfile.
</p>
<p>
I've created a few small programming languages and related tools, including a <Link href="/compiling-a-forth"> Forth compiler</Link>, a <Link href="/lisp-to-javascript-compiler">Lisp-to-JavaScript compiler</Link>, which I turned into an <Link href="/lisp-compiler-optimizations">optimizing compiler</Link>, and for which I wrote a <Link href="/compiling-lisp-to-bytecode-and-running-it">bytecode VM</Link>.
I also built an <Link href="/adding-for-loops-to-an-interpreter">interpreted language</Link> with a C-style syntax, which I <Link href="/profiling-and-optimizing-an-interpreter">profiled and made faster</Link>; I later added a <Link href="/a-custom-webassembly-compiler">WebAssembly compiler</Link> and a <Link href="/adding-a-line-profiler-to-my-language">line profiler</Link>. I also <Link href="/porting-boolrule-to-rust">ported an expression engine</Link> to Rust.
I also built an <Link href="/adding-for-loops-to-an-interpreter">interpreted language</Link> with a C-style syntax, which I <Link href="/profiling-and-optimizing-an-interpreter">profiled and made faster</Link>; I later added a <Link href="/a-custom-webassembly-compiler">WebAssembly compiler</Link> and a <Link href="/adding-a-line-profiler-to-my-language">line profiler</Link>. I also <Link href="/porting-boolrule-to-rust">ported an expression engine</Link> to Rust, and looked into how a compiler can <Link href="/a-tiny-compiler-for-data-parallel-kernels">rewrite kernel loops for explicit data parallelism</Link>.
</p>
<p>
One of my favorite compiler hacks is <Link href="/adding-defer-to-the-typescript-compiler">adding Go's defer to the TypeScript compiler</Link>.
</p>
<p>
Below, you can see my <Link href="/icepath-a-2d-programming-language">2D programming language</Link> calculating the first ten numbers in the Fibonacci sequence.
Expand Down
331 changes: 331 additions & 0 deletions posts/adding-defer-to-the-typescript-compiler.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,331 @@
---
title: "Adding Go's defer to the TypeScript Compiler"
date: "2026-08-02"
tags: ["typescript"]
description: "Forking tsc to support Go's defer."
---

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.

In Go, the `defer` statement delays the execution of a function until the
surrounding function finishes. It's most commonly used to keep resource
acquisition and cleanup together, like acquiring a semaphore:

```go
func withSemaphore(ctx context.Context, sem *semaphore.Weighted) error {
if err := sem.Acquire(ctx, 1); err != nil {
return err
}
defer sem.Release(1)

// ... protected work
return nil
}
```

TypeScript doesn't have a strict equivalent of `defer`. You might use
`try`/`finally`, like:

```ts
async function readFile(path: string) {
await sema.acquire();
try {
// ... use resource
} finally {
sema.release();
}
}
```

But that's kinda ugly.

For fun, we can hack in a `defer` statement to the TypeScript compiler and get
Go-like semantics. Since `defer` doesn't map to an existing JavaScript feature,
we need to output JavaScript code that makes it work at runtime just like it
does in Go.

So the goal is to be able to write TypeScript code like this:

```ts-with-defer
async function readFile(path: string) {
await sema.acquire();
defer sema.release(); // New!

// ... use resource
}
```

## The TypeScript Compiler

The TypeScript compiler (`tsc`) is mostly a static analysis engine. Its
complexity lies in type-checking a fundamentally dynamic language, and
supporting extremely incremental compilation to meet latency expectations in an
IDE.

Lucky for us, we don't need to worry too much about types or other analysis in
order to add our `defer` statement. `tsc` already has the machinery for
"recognize syntax X, replace it with equivalent syntax Y."

For example, when compiling for ES5:

```ts
class Foo {
x = 1;
}
```

Might become something like:

```ts
function Foo() {
this.x = 1;
}
```

Conceptually, adding `defer` means doing another tree rewrite. `tsc` already
performs a number of AST-to-AST transformations (e.g. optional chaining `?.`
becomes conditional expressions) so we don't need to add new tooling.

There's some complexity to dig into, but at a high level, we'll take an AST with
`defer`:

```ts-with-defer
function f() {
defer cleanup();
work();
}
```

And transform it into something like:

```ts
function f() {
const __defers = [];
try {
__defers.push(() => cleanup());
work();
} finally {
// Pop and invoke
}
}
```

First, we need to teach `tsc`'s parser that `defer` is a statement. There's a
list of syntax kinds that we add `DeferStatement` to and we define it as taking
a single expression operand.

There are a few checks we need to perform, like ensuring the `defer` statement
appears inside a function body, making sure that the expression is callable, and
ensuring `tsc` performs its usual recursive checks:

```go
func (c *Checker) checkDeferStatement(node *ast.Node) {
c.checkGrammarStatementInAmbientContext(node)

// A defer is tied to the lifetime of its containing function
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)
}

// Only calls are supported, which keeps capture/lowering unambiguous
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
}

// Reuse normal call checking (callable callee, argument types, etc.)
c.checkExpression(expression)
}
```

The actual transformation code is quite verbose so rather than reproduce it
here, I'll instead dig into the design decisions I made and tell you more about
how the transform works.

## How I Think defer Should Work

To match Go's behavior, the callee, receiver, and argument values are captured immediately:

```ts-with-defer
let x = 1;
defer console.log(x);
x = 2;
```

It must print `1`.

An extreme case we need to survive is the callable method being redefined like:

```ts-with-defer
const logger = {
log(message: string) {
console.log("old:", message);
},
};

defer logger.log("hello");

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

Even if `logger.log` is reassigned later, the deferred call still invokes
the original method. This matches Go's semantics, where the function value,
receiver, and arguments are all evaluated when execution reaches the `defer`
statement.

Any function that contains at least one `defer` gets a small stack, and each
reached `defer` statement pushes a closure onto that stack. When the function
exits, the stack is drained in reverse order (last-in-first-out).

Registration happens when execution reaches the defer, not when the function
starts. So a `defer` inside an if only runs if that branch ran, and a `defer`
inside a loop registers once per iteration.

So a user writes:

```ts-with-defer
async function readFile(path: string) {
await sema.acquire();
defer sema.release();

return await fs.readFile(path, "utf8");
}
```

Which is transformed like:

```ts
async function readFile(path) {
const stack = [];

try {
await sema.acquire();

const receiver = sema;
const method = receiver.release;

// Register cleanup only if execution reaches the defer statement.
stack.push(() => method.call(receiver));

return await fs.readFile(path, "utf8");
} catch (error) {
// Save the original error so cleanup can still run.
} finally {
// Run registered callbacks in reverse order.
// In an async function, await each cleanup before moving to the next one.
// If cleanup also throws, aggregate the failures.
}
}
```

Rather than invent semantics for `defer await`, I simply reject it as an error.
I worried that a user would assume that the `await` would resolve before the
rest of the function runs. Besides, if the containing function is async, every
deferred call is awaited sequentially during cleanup.

## More On Errors

The cleanup code can fail too, so the transform follows three rules:

- Every deferred call runs, even if an earlier one throws.
- The original error from the function body is preserved.
- If multiple errors occur, they are reported with an `AggregateError`.

Go doesn't need an aggregation policy like this because ordinary errors are
values. A deferred call's returned error is not handled unless the user
explicitly decides to do something with it. JavaScript exceptions are control
flow. So when the compiled `defer` code throws an error it needs to decide
whether that replaces, combines with, or is ignored in favor of the original
failure.

Since async functions turn both throws and rejected awaits into promise
rejection, the transform needs one clear rule for both sync throws and async
cleanup rejections:

```ts-with-defer
async function f() {
defer asyncCleanup();
throw new Error("body");
}
```

If `asyncCleanup()` also rejects/throws, `f()` rejects with an `AggregateError`.

```ts
AggregateError([
Error("body"),
cleanupError,
]);
```

## So Let's Ship It?

Ironically, implementing `defer` convinced me it doesn't belong in TypeScript.

The more edge cases I implemented, the less convinced I became that `defer`
belongs in TypeScript. Go's `defer` feels way more natural because errors are values
rather than control flow. In TypeScript, once cleanup can throw or reject, you
need policies for aggregation, precedence, and async execution that simply don't
exist in Go (panics are handled through Go's separate panic and recover
semantics).

But hope is not lost. The
[ECMAScript Explicit Resource Management](https://github.com/tc39/proposal-explicit-resource-management)
proposal tackles the same problem from a different direction.

Take the `async-sema` example from above, instead of `defer`:

```ts-with-defer
async function readFile(path: string) {
await sema.acquire();
defer sema.release();
return await fs.readFile(path, "utf8");
}
```

We can use a
[Disposable](https://www.typescriptlang.org/docs/handbook/release-notes/typescript-5-2.html#using-declarations-and-explicit-resource-management):

```ts-with-using
async function readFile(path: string) {
using _ = await acquirePermit(sema);

return await fsReadFile(path, "utf8");
}

// The above assumes a helper like this,
// which could be embedded in the library
async function acquirePermit(sema: Sema): Promise<Disposable> {
await sema.acquire();

return {
[Symbol.dispose]() {
sema.release();
},
};
}
```

I would prefer not to have to define an unused variable like `_` but disposable
resources work by cleaning them up when they fall out of scope.

So my preferred but sadly unsupported syntax would be:

```ts-with-using
async function readFile(path: string) {
using await acquirePermit(sema); // Not supported!

return await fsReadFile(path, "utf8");
}
```

You can find the MVP implementation of `defer` on
[this branch of my TypeScript fork](https://github.com/healeycodes/typescript-go/tree/defer-v1).
Loading