diff --git a/components/code.tsx b/components/code.tsx index 357547f..cd87310 100644 --- a/components/code.tsx +++ b/components/code.tsx @@ -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"; diff --git a/lib/prism-ts-with-defer.js b/lib/prism-ts-with-defer.js new file mode 100644 index 0000000..0420615 --- /dev/null +++ b/lib/prism-ts-with-defer.js @@ -0,0 +1,6 @@ +(function (Prism) { + const tsWithDefer = Prism.languages.extend("typescript", {}); + tsWithDefer.keyword.unshift(/\bdefer\b/); + + Prism.languages["ts-with-defer"] = tsWithDefer; +})(Prism); diff --git a/lib/prism-ts-with-using.js b/lib/prism-ts-with-using.js new file mode 100644 index 0000000..ed035bc --- /dev/null +++ b/lib/prism-ts-with-using.js @@ -0,0 +1,6 @@ +(function (Prism) { + const tsWithUsing = Prism.languages.extend("typescript", {}); + tsWithUsing.keyword.unshift(/\busing\b/); + + Prism.languages["ts-with-using"] = tsWithUsing; +})(Prism); diff --git a/pages/index.tsx b/pages/index.tsx index 6c208e9..e2ee1ca 100644 --- a/pages/index.tsx +++ b/pages/index.tsx @@ -53,14 +53,14 @@ export default function Home({ allPostsData, description, words }) { News 23 times.

- 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.

{/* Maybe link to latest? */} {/*

My latest article is {allPostsData[0].title}.

*/}

- I enjoy understanding how things work and making them faster. Like how SIMD can make some programs orders of magnitude quicker, or how a compiler can rewrite kernel loops for explicit data parallelism. + I enjoy understanding how things work and making them faster. Like how SIMD can make some programs orders of magnitude quicker.

I wrote one of the fastest disk-usage programs on macOS by @@ -69,14 +69,17 @@ export default function Home({ allPostsData, description, words }) { also showed how to beat the performance of grep by just using goroutines.

- I like learning by building things from scratch; like a JavaScript runtime, a tiny shell, and a fair, and cancelable semaphore in Go. + I like learning by building things from scratch; like a JavaScript runtime, a tiny shell, and a fair, and cancelable semaphore in Go.

My experimental package manager uses simple concurrency patterns to be faster than every package manager aside from Bun (mine is 11% slower) when cold-installing from a lockfile.

I've created a few small programming languages and related tools, including a Forth compiler, a Lisp-to-JavaScript compiler, which I turned into an optimizing compiler, and for which I wrote a bytecode VM. - I also built an interpreted language with a C-style syntax, which I profiled and made faster; I later added a WebAssembly compiler and a line profiler. I also ported an expression engine to Rust. + I also built an interpreted language with a C-style syntax, which I profiled and made faster; I later added a WebAssembly compiler and a line profiler. I also ported an expression engine to Rust, and looked into how a compiler can rewrite kernel loops for explicit data parallelism. +

+

+ One of my favorite compiler hacks is adding Go's defer to the TypeScript compiler.

Below, you can see my 2D programming language calculating the first ten numbers in the Fibonacci sequence. diff --git a/posts/adding-defer-to-the-typescript-compiler.md b/posts/adding-defer-to-the-typescript-compiler.md new file mode 100644 index 0000000..eff5b04 --- /dev/null +++ b/posts/adding-defer-to-the-typescript-compiler.md @@ -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 { + 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).