跪拜 Guibai
← Back to the summary

Cloudflare's Rust TypeScript Port Matches tsc Error-for-Error, Then Diverges at the Exit Code

Cloudflare Rewrote TypeScript in Rust: I Ran the Same Code Against Both, Identical Errors, Different Exit Code

By Flynt

When TS7 (the Go-native compiler) was released in early July, I wrote up my upgrade experience and thought the matter was settled: Microsoft had rewritten the compiler themselves, 10x speed, ecosystem endgame. Then on October 20 Cloudflare published a blog post literally titled "Introducing tsrs: A Port of TypeScript to Rust". My first reaction was to rub my eyes — didn't we already have a Go version? Why would someone rewrite a compiler again? And Cloudflare led it, together with VoidZero, incubated in the Oxc toolchain, sitting at 371 points and over a hundred comments on Hacker News.

After reading the blog post and then going through the compatibility docs on GitHub, I found this more interesting than "another rewrite". That same day I installed tsrs on my 1-core 2G small machine and ran the same code against tsc. This post records the process and two findings I didn't expect.

First, what this is and is not

The blog post specifically emphasizes: it is not a fork. tsrs is a function-by-function port of the TypeScript 7 compiler (the Go implementation). The GitHub compatibility doc puts it bluntly: "Go code is the spec" — as long as diagnostics, diagnostic order, message text, and exit codes do not match the Go version, it counts as a tsrs bug, even if tsrs prints something that looks better.

I think that statement matters. In the past few months, AI bulk ports of TypeScript were "experimental projects" everywhere, and Microsoft didn't pay attention. This time the TypeScript team is clearly collaborating, and the npm package version carries the upstream commit: the build I installed was 0.18.0-ts7.1.0-dev.20260929; the date prefix is the upstream commit snapshot it was ported from, and the suffix corresponds exactly to microsoft/TypeScript@b85298b6a81f, a 7.1.0-dev commit. The repo runs the official TypeScript test suite every night to generate a compatibility matrix, the same playbook Cloudflare uses maintaining Vinext to stay in sync with Next.js.

So why a Rust version when a Go version exists? I'll pick the practical reason out of the official ones: when Go compiles to WebAssembly, it has to ship the GC along with it; Rust doesn't. In other words, TS7 basically has no chance of running in the browser, and tsrs can. They've already released the @tsrs/wasm package, plus a playground that runs in the browser. For the remaining reasons (performance, memory), the blog gives official numbers: up to 8x faster than TS 7.0 in the same configuration, and about 70% less memory.

I'll leave those numbers here; my own measured feel was far from 8x, and I cover that below.

What I cared about most wasn't speed

A slower compiler just means waiting a few extra seconds. But when you swap out the type checker, the biggest fear is that the errors it reports are not the same set as before. If CI replaces tsc with another compiler and an error that should have been reported doesn't appear, that isn't optimization; that's letting a production bug through.

So instead of benchmarking first, my first move was to build an "audit target range": a layered little project with models, utils, services, plus a pile of generated catalog modules, eventually expanded to 199 .ts files. Then I planted 6 categories of type errors I have actually seen in real work:

The last one I planted was more devious. The function signature is <T extends Auditable>(a: T, b: T) => T, but the argument is a StockRecord without an auditLog field. In that case the compiler first has to fail inference of T, fall back to Auditable, and then accessing sku and warehouse produces three TS2339 errors in cascade. Whether cascading errors are reported, and how, is the clearest sign of whether two compilers are running the same type-inference logic. It's no surprise that both report a single error; the inference fallback has to ripple out identically for them to really look like the same implementation.

Those 6 errors produced a total of 10 diagnostics. I diffed the raw output of both:

$ diff <(tsc -p . 2>&1) <(tsrs -p . 2>&1) && echo SAME
SAME

Character-for-character identical. Error codes, file line numbers, message text, print order, all the same. Four of the 10 were the cascade from that generic inference fallback (1 TS2741 plus 3 TS2339), and even that aftermath of inference failure was identical.

This matches the numbers in the official compatibility doc: in the TypeScript conformance test suite, tsrs aligns with 13,458 / 13,462 error baselines, missing 4. My small sample certainly wasn't going to hit those four corners, but all 10 matching at least means that in everyday error scenarios it really is "the same set". Also, the known issue the blog mentioned — "diagnostic print order currently differs" — did not reproduce at my project's scale. It might take a bigger repo or parallel workers to diverge. I didn't verify that, so I'm not making claims about it.

An unexpected difference: exit code

After verifying consistency, I took a look at the script's exit code and found something interesting.

On the same project with planted errors, after running:

$ tsc -p tsconfig.json; echo $?
1
$ tsrs -p tsconfig.json; echo $?
2

tsc 7.0.2 exits with 1, tsrs with 2. Switching to @tsrs/wasm also gives 2, and its README even marks it with a comment: result.exitCode; // 2. A project with no errors returns 0 from both.

So the big direction is fine: nonzero means there are errors, and most CI checks like if ! tsc are unaffected. But if your pipeline has old scripts that hardcode -eq 1, or tooling that relies on the exit code to distinguish "compile errors" from "configuration errors", you really need to check before upgrading. The tsrs wasm docs define its exit-code semantics: 0 through 4 are normal compiler states, and 5 is a wasm-level crash or out of memory.

Whether this difference should be blamed on tsrs is hard to say — it tracks 7.1.0-dev, and the behavior may have changed upstream between 7.0 and 7.1. I didn't go dig through Microsoft's changelog; I'm just recording the observed behavior. But an exit-code change is exactly the kind of gap that is easiest to miss when swapping checkers, because it doesn't show up in compiler output.

Speed: feel on a 1-core 2G machine

I'm putting the benchmark last because it really isn't the point. My machine conditions are entirely incomparable to the official bench: a 1-core 2G container, and a project at 199 files and a bit over ten thousand lines, not a monorepo.

I ran a few rounds with bash's time and took stable values:

Project size tsc 7.0.2 tsrs 0.18.0
35 files ~125ms ~70ms
199 files ~195ms ~87ms

That's roughly 1.8x to 2.2x, a long way from the official 8x. The reason isn't hard to see: the official numbers are large repos on big machines compared under the same configuration, where type checking itself dominates. In a small project like mine, process cold start accounts for nearly half the time — both take 35-45ms for --version — so it can't get much faster. If you are on a small or mid-size project like me, don't expect installing tsrs to divide compile time by 8; around 2x is a more realistic expectation.

And I have to be clear about the baseline: tsrs tracks 7.1.0-dev, tsc is 7.0.2, so strictly speaking this is not a comparison of the same source version. The official 8x chart is also against TS 7.0, and TS 7.1 beta itself is 2.5x faster than 7.0, with part of those optimizations back-ported from tsrs. This track is now a back-and-forth race; nobody should rush to a "who crushes whom" conclusion.

WASM is the real differentiator, but don't mythologize it

I tried @tsrs/wasm separately. Same version number, compiled to wasm32-wasip1, runs on Node 22+ and in browsers. Using its API to check my 199-file project:

run1: exit=2 1075ms mem=110MB
run2: exit=2 417ms mem=110MB

The first run includes wasm initialization: just over a second. After that, a bit over 400ms, 110MB memory, and all 10 errors, not one missing. It also supports an in-memory filesystem; throw a few strings at it in a browser and you get JSON diagnostics with UTF-16 coordinates, no server needed. The official playground runs exactly this way.

TS7 really can't do that — the Go runtime can't go into Wasm. So scenarios like "type checking directly in the browser" and "running a full development environment inside a Cloudflare Worker sandbox" currently only have tsrs as a path. But it is single-threaded right now, and on large repos the gap between the wasm version and the native version will be obvious. Per the official docs, this is a current limitation, not the end of the design.

Time to pour cold water

After testing, my overall judgment is: the direction is right, the foundation is solid, but there are a few hurdles before using it as a production tool, and some of them I'd directly warn against.

The hardest one: Windows users can't install it right now. COMPATIBILITY.md states clearly that 0.18.0 and earlier only have binaries for macOS arm64 and Linux x64/arm64 (glibc/musl). Windows x64 is in the "next version" configuration, and Intel Mac isn't there either. The wasm version is platform-independent, but that's a separate matter.

Going further down, the gaps get piecemeal. --init and --showConfig are simply unsupported, --locale is ignored (English-only messages), and --generateTrace is missing. --watch works on Linux and macOS, with 39/42 watch scenario baselines passing per the official docs, but on Windows it reports unsupported directly.

Version calibration is also a practical annoyance. The latest tag on npm currently points to the 7.1.0-dev port, which follows the upstream dev branch. Installing today and installing next week can give you different snapshots. Using it in CI means your type-check results drift with upstream — even though in theory "whatever the Go version does is what it should do", the dev branch's own semantics can change.

So my conclusion is scenario-dependent: CI on Linux/macOS, build environments that want to save memory, and any scenario that needs in-browser type checking are worth trying now; Windows-primary dev machines, or old pipelines with hard dependencies on exit codes and diagnostic formats, should wait for a stable release. Both are experimental versions; if it loses, it's just one npm command to roll back.

As for "should we migrate away from tsc later", I don't have an answer for now. tsrs and TS7 are already in the same family (optimizations flow both ways), and neither side has a stable release in sight, so picking a side now is meaningless. Unless you're stuck on the WASM scenario — in that case, tsrs is currently the only thing that can do it.