← All posts
12 min read

tsrs

A Rust port of TypeScript, written entirely by agents, cut our typecheck from 80 seconds to 7.

Max Schwenk
Head of AI Developer Experience

For a coding agent to be maximally productive, it needs to be able to validate its work in the tightest possible loop. At Owner that means lint, typecheck and format have to run on a laptop, CI, a cloud sandbox, wherever the agent runs. tsrs is what makes that possible for us, and Cloudflare and VoidZero are taking over tsrs as part of Oxc, in close collaboration with the TypeScript team.

Our backend monolith, Olympus, is about 37,000 TypeScript files typechecked as one Program. On TypeScript 7.0 a single-threaded check of it needed 19 GiB of memory and 80 seconds of wall time. Running tsgolint next to it, which spins up its own checker, doubles the memory usage, and adding language and dev servers puts you north of 50 GiB for what is, on paper, basic validation.

Even on a $3,000 MacBook with 36 GiB of memory, engineers couldn't run several agents in parallel without risking their machine locking up. We came up with mitigations, none of which seemed like a long-term scalable solution.

  • Cloud sandboxes. Local development had become almost non-viable, so we pushed developers and their agents into cloud sandboxes. Picking resource limits for the sandboxes up front, before you understood the scope of the work, left you choosing between overpaying for the p99 peaks or handling/accepting out of memory crashes.
  • Asking agents to take turns. We asked politely in steering files and hooks. Agents don't always have their Listening Ears on, and they find ways around hooks.
  • Splitting tsconfigs. Tactical fixes like splitting the config into slow/fast variants. It worked, but it felt like a band-aid. More robust solutions like Project References meant significantly re-architecting the codebase.
  • Breaking up the monolith into internal packages. Turborepo's cache can help skip work you've already done, but only once the code lives in packages. This type of extraction can be a tedious effort that's hard to land with thousands of commits to main per day, and again, requires a re-architecture.

Backstory

My previous employer was one of the first companies to run TypeScript's Go compiler on a really big private codebase, compiling nightlies from source at the pre-alpha stage. We sent the TypeScript team bug reports and benchmarks, and occasionally upstreamed fixes.

Having engaged in good faith with the existing implementation, and having exhausted that avenue at Owner, it made sense to try something different. Claude Opus 5.5 felt like a step change from every model before it, and it wasn't obvious everyone had noticed yet. I unleashed it on Olympus with a profiler, the TypeScript Performance Wiki, Typeslayer and a lot of tokens. The next morning I opened the first of a series of pull requests upstream to cut the Go compiler's memory usage.

On September 30th, after probing an agent to dig deeper into the memory usage and hitting walls, I found myself thinking:

I wonder if Opus 5.5 could one-shot a port of the TypeScript compiler to Rust.

Jarred Sumner had ported Bun from Zig to Rust earlier this year. Theo Browne had been chipping away at a TypeScript Rust port of his own for a bit. If it was useful for Owner, great. If not, I'd throw it away.

Port first, optimize later

I wrote zero code. Not "barely any". Zero. I asked Opus for a faithful port of TypeScript Go to Rust, and laughed out loud when one of the first files it created was docs/PORTING.md, the exact filename Bun's port from Zig to Rust had used.

The ask was scoped to the smallest thing that would be valuable to Owner: a typechecker that was correct, fast, and used less memory than Go. We did not need:

  • JavaScript emit. Our apps transpile with tools like swc.
  • A language server on day one. Most of us had stopped using editors anyway.

Both came later. The port followed these rules:

  • Same everything. Same algorithms, function structure, evaluation order and diagnostics, pinned to one upstream commit. Redesign was out of scope.
  • One Rust crate per Go package. Function signatures and contracts first with mocked out implementations, so that agents could work in parallel worktrees without waiting for each other to finish.
  • Tests as the spec. The TypeScript team had left us a gift: the JavaScript-to-Go port left behind 15,197 conformance cases. The suite doesn't prove the compiler correct, but just as the conformance suite helped the TypeScript team feel confident in the Go port, it helped us feel confident in the Rust port.

After some back and forth, the agent started to rip with 20-plus subagents in parallel. Around 1:30 AM I told it to work overnight until it had something working. I woke up to a working typechecker and assumed it was lying. It mostly wasn't: on the first end-to-end run, 78% of TypeScript's error baselines matched byte for byte, and another 21% only differed because there was no type printer yet. When I added one, the match rate went to 99.97%, all but four of 13,462, and every type and symbol baseline matched.

Then I pointed it at Olympus. It was already faster than the Go compiler, but it used more memory. I remember vibrating with excitement when reading the benchmark. I didn't know much Rust, but knew Go's memory was mostly millions of small objects in a garbage-collected heap, and that Rust would let me choose their size and lifetime. Less memory wasn't guaranteed; the first build had just proved that. It was reachable, and it seemed that fewer, smaller allocations would make it faster too.

The loop

Every experiment ran the same way.

  1. Take a baseline.
  2. Read the profile.
  3. Change one thing.
  4. Diff the output against Go.
  5. Benchmark.
  6. Decide to keep it or throw it away, and write the decision down so no agent tries it twice.

Claude was good at coming up with experiments. Measuring them was the hard part. Owner gave me a powerful machine, but once a few subagents were each building a release Rust compiler on it, it would buckle, and nothing I benchmarked while that was going on could be trusted. So I split the work by machine. Implementation went out to our internal cloud sandboxes, which start in a couple of seconds and can scale infinitely. And every benchmark ran on Depot CI runners: the same hardware every time, with nothing else on the box.

The repo has a bar to prevent agents from optimizing endlessly. A change lands only if it reduces single-threaded instructions by at least 1%, wall time by 2% or peak memory by 5%, with byte-identical output. Otherwise the codebase could be slopped away into micro-optimizations.

The project needed guidance from people who know what good Rust looks like. Anthony Shew from Turborepo suggested pointing the agents at the largest Rust codebases, so they read how oxc, rust-analyzer, Ruff, Biome, SWC, Rolldown, Turborepo and Bun write Rust. From that they derived rules the repo now enforces, plus a ratchet so things can't get worse.

We also found public codebases to reflect some of the gains we were seeing: ORM entities, deeply generic types, big declaration files. The suite is 17 projects, VS Code, MikroORM and Drizzle among them.

Hill climbing on memory

I asked Opus to list the common ways to cut memory in Rust and dispatch a subagent per approach, then spent a few days watching agents probe memory management techniques that were less familiar to me coming from a Node.js background.

Stop copying source text one checker · footprint

The lazy JSDoc parser copied the whole file's text for every node it touched. Three gigabytes of copies.

memory 19.53 19.53 GB −21%
Lazy member resolution one checker

Build a generic type's members when something asks for them, not all of them on first touch.

memory 15.34 15.34 GB −23%
check time 26.3 26.3 s −13%
Smaller objects one checker · data layout pass

Same algorithms, tighter layout: smaller headers, rarely used fields moved out of the main struct, packed lists.

memory 13.94 13.94 GB −21%
check time 21.2 21.2 s −14%
Pointer compression one and four checkers

An 8-byte address becomes a 4-byte distance into one reserved block.

peak memory −14 to 15%
instructions +6.5%
Free checked leaf files VS Code · low checker counts

A test file that nothing imports can be dropped as soon as it's checked.

peak memory −13 to 16%
Repeated-inference memo single-threaded · instructions

The checker kept running the same type-inference walk on the same inputs. Now it remembers the answer.

VS Code −7.15%
T3 Code −11.89%
Work stealing Linux workload · wall time

An idle checker takes unstarted files from the busiest one.

4 checkers −13.8%
8 checkers −17.0%
Seven separate A/B runs, each at its own revision and on its own machine. The percentages don't multiply.

Pointer compression. A pointer is normally an 8-byte address. But everything tsrs creates lives inside one big block of memory it reserves up front, so a pointer doesn't need the whole address. It only needs to say how far into that block the object sits, and a 4-byte number is enough to point anywhere in 32 GiB of it. 32 GiB sounds like a lot to set aside, but setting aside isn't using: the operating system only hands over memory for the parts you actually touch. Halving every pointer took 14 to 15% off peak memory and cost about 6% more CPU work to turn the short number back into an address. Worth it.

Where is the object? one reserved block, 32 GiB
What it saves one Symbol
40 bytes
× 12.8 million of them 489 MB
A pointer can hold the whole address, or just how far into tsrs's one big block the object sits. The short version is half the size, and objects are full of pointers.

Lazy members. The one that matters most for code like ours. A generic ORM type can have hundreds of properties. When the checker asks for user.id, building a specialized symbol for every other property is a waste of time. On Olympus, tsc built 15.7 million member symbols and read 2.95 million. The rest were built for the vibes. The lazy table keeps the original members plus the substitution recipe, replace T with User, and materializes a property when something asks for it. Inherited members, signatures and recursive types all still have to come out right. It's the same idea as that first upstream pull request.

Eager Entity<User>
user.id already built, nothing to do
0 of 240 members built
Lazy Entity<User>
user.id already built, nothing to do
0 of 240 members built
A generic ORM type with hundreds of members. Eager resolution builds every specialized member on the first touch. The lazy table builds the one you asked for, and the whole table only when something needs all of it.

Freeing leaf files. A test file that nothing imports and that declares nothing global is finished once it's checked, so in a noEmit run tsrs drops its syntax tree and binder data and keeps the diagnostics. Actual imports, exports and augmentations decide whether that's safe, not the filename, because filenames lie. On VS Code that's 13 to 16% off peak memory at low checker counts. Above sixteen checkers it's off, because handing pages back to the OS costs more than it saves.

syntax + binder data held 100%
A noEmit run over a toy graph. A test file nothing imports is done once it's checked, so its tree goes and its diagnostics stay. The fixture other tests import and the test that declares a global are not leaves, whatever their filenames say.

Parallel checking

The Go compiler parses and binds every file once, then hands the files out to several checkers, and each checker builds its own private type graph. tsrs inherits that, and it's an area where we had trouble improving the architecture without tradeoffs: every checker ends up with its own copy of every library type it touches. Eight checkers, eight private copies of React. We tried sharing those types across checkers and couldn't find a design that kept the output identical without costing wall time, so the copies stay.

shared parsed + bound front end every file, once
checker 1
lib.domReactzodExpressMikroORM…
checker 2
lib.domReactzodExpressMikroORM…
checker 3
lib.domReactzodExpressMikroORM…
checker 4
lib.domReactzodExpressMikroORM…
checker 5
lib.domReactzodExpressMikroORM…
checker 6
lib.domReactzodExpressMikroORM…
checker 7
lib.domReactzodExpressMikroORM…
checker 8
lib.domReactzodExpressMikroORM…
1 checker 1 copy of every library type
What the Go compiler does and tsrs inherits. Parsing and binding happen once. Every checker then instantiates the same library types for itself, so eight checkers hold eight copies of React.

So the work went into who checks what. First, files that share libraries share a checker. Go deals files out in order; tsrs groups them by the modules they import, so fewer checkers rebuild the same types. On Olympus at four checkers that alone cut peak memory by 11% and the wall time by 16%. Second, work stealing: with a fixed assignment the slowest checker decides the check time, so an idle checker now takes unstarted files from the busiest one.

Twenty files, round-robin onto four checkers. Top: each checker works only its own list. Bottom: an idle checker takes unstarted files from the busiest one. Sizes are made up; on the Linux workload the real gain was 13.8% less wall time at four checkers and 17% at eight.

The hard part wasn't the stealing. It was making the output stop caring who did the work. We found cases where changing the assignment changed which declaration a cache saw first, and that changed a diagnostic or a printed type. A compiler whose errors depend on thread scheduling is a random number generator with opinions. So:

  • By default tsrs makes the same choice no matter which checker gets there first.
  • A Go-compatible mode keeps the reference's assignment, for comparing against upstream.
  • CI runs a determinism gate. Single-threaded, default scheduling and randomized assignment all have to produce identical output. The gate tests itself too: we feed it a fake compiler whose output depends on thread order, and if the gate doesn't fail on that, the gate is broken.

How do we gain confidence in correctness?

A faster compiler that finds fewer errors is a broken compiler with good marketing.

  • Three outputs, not one. CI runs TypeScript's conformance suite and compares errors, inferred types and the symbols each name resolves to. Zero errors is also what you get from a compiler that does nothing.
  • Broken programs on purpose. We inject known errors into real projects and check that tsrs still rejects them. A nightly job runs 200,000 parser mutations against the reference and saves every difference.
  • Shadow mode. The caches that make the checker fast can also redo the slow work every time and check that the cached answer matches. That's how we find out a cache is wrong.
  • Continuous reconciliation with a real app. We ran Olympus's typecheck in CI on both the Go and Rust implementations and tracked disagreements. Tens of thousands of runs over a week, zero disagreements.
  • Miri, with a test that has to fail. Miri flags memory misuse the compiler can't catch. CI runs it over the arena code, then feeds it a deliberately broken test it has to reject. If the broken test ever passes, the tool wasn't checking anything.

These checks caught a shortcut that passed the whole suite and still dropped errors and a memory bug that only surfaced in one optimized build. None of it is a proof. We continue to iterate towards exact behavioral parity with the Go compiler.

Numbers

A caveat first, because benchmarks are a sensitive subject. The benchmarks in the repo try to be as neutral as possible: 17 public projects, both compilers at the same TypeScript commit and the same checker count. Across that suite tsrs uses about 3× less memory and runs 2.5 to 5× faster than the nightly, and those numbers move as everyone keeps shipping. What follows is our internal benchmark on Olympus specifically. Your mileage may vary, but this was the impact for us.

Compiler Checkers Wall time Peak memory tsrs is
tsgo 7.0.2 4 61.4 s 36.0 GiB 7.0× faster, 5.9× less memory
tsgo 7.0.2 8 56.6 s 49.8 GiB 9.8× faster, 6.6× less memory
tsgo 7.1 nightly, Sept 29 4 43.4 s 28.8 GiB 5.0× faster, 4.7× less memory
tsgo 7.1 nightly, Sept 29 8 40.8 s 40.1 GiB 7.0× faster, 5.3× less memory
tsrs 0.14.0 4 8.75 s 6.1 GiB
tsrs 0.14.0 8 5.80 s 7.5 GiB
tsrs 0.14.0, default settings 16 4.24 s 9.5 GiB

A developer with an even larger repo than Owner's told me that tsrs will save them millions a year in infra costs.

Leveraging the wins from tsrs into linting

Once the typechecker was fast, the obvious next question was the linter. oxlint's type-aware rules run on a separate backend called tsgolint, which embeds the Go checker. As a result, a type-aware lint pass was its own 28 GiB problem. tsrslint is that backend ported onto tsrs: all 60 rules, the same protocol, the same diagnostics, and you opt in by pointing oxlint at a different binary. Across eight open-source projects with all 60 rules on a 4 vCPU Linux machine, it lints in 37.3 seconds against tsgolint's 169.4, 4.5× faster, and peaks at 11.5 GiB against 45.0, 3.9× less memory. We've been using it for a few weeks now and it's been working great.

What changed at Owner

We're using tsrs on our entire monorepo. The speed is nice. The memory is what changes how we work.

  • Typecheck and lint are feedback loops again. Typecheck is seven seconds in CI and about twelve on a MacBook. Type-aware lint runs on tsrslint, so it no longer brings its own 28 GiB checker to the party.
  • Brittle steering is gone. Multiple agents can now run in parallel locally, without having to be careful about doing too much parallel work and risking instability.
  • Localdev is viable again. A 36 GiB MacBook can hold the app, the editor and a couple of agents each checking their own worktree.
  • Resourcing for sandboxes and CI agents can come down. We do not have to reserve memory for the peak anymore. Projected savings from these changes amount to hundreds of thousands of dollars a year.
  • Cheap checks before expensive ones. Lint, format and typecheck are now fast enough to run as a preflight ahead of the E2E CI stack without adding wall time. We can discard builds we know are not mergeable earlier in the pipeline.

Where things stand, October 19

Most of the numbers above are from the first eight days.

  • Upstream got faster. The TypeScript team has been receiving upstream contributions from an energized community and been hill climbing on performance themselves with exciting results.
  • More ports showed up. Theo Browne released a Rust port of the compiler in mid-October, and Bun shipped its own checker.
  • tsrs moved to Oxc. The repo now lives at oxc-project/tsrs. Boshen Chen and the Oxc team maintain the parser, linter and bundler a large part of the JavaScript ecosystem already runs on. I wanted the project with people who maintain Rust developer tools for a living, not one person with a day job. They cut two releases on their first day, and there's now a browser playground.

Check it out:

pnpm add -D tsrs
pnpm exec tsrs --noEmit

Acknowledgements

To the TypeScript team, starting with Jake Bailey, an extremely helpful and patient partner going back to the Go port. To Anders Hejlsberg, Daniel Rosenwasser, Andrew Branch, Ryan Cavanaugh, Wesley Wigham, Gabriela Araujo Britto and the rest of the team, for pouring so much love into the language and the compiler. Meeting Anders, who created the language so many of us built our careers on, was humbling.

To Boshen Chen, Dimitri Mitropoulos, Evan You, Christoph Nakazawa, and Cameron Clark from Cloudflare, for their support and collaboration on this effort. To Walker Boyle at Notion, for working alongside me to scale this up on another massive codebase and sharing loads of invaluable tips and insights over the years.

And to Owner, to my manager Max Presman, and to Ryan Selden, who kept pushing me to find out how far agents can really go. If a week like this one sounds like fun, we're hiring.