Benchmarking tsrs Against tsc: Testing an AI-Generated Rust TypeScript Compiler Before It Hits Your Build Pipeline

Benchmarking tsrs Against tsc: Testing an AI-Generated Rust TypeScript Compiler Before It Hits Your Build Pipeline

pr0h0••25 min read•coding
AI Usage (88%)
On this page

Introduction

This post is about benchmarking tsrs against tsc before either one gets near your build pipeline: what to measure, how to capture a baseline you can defend, and which divergences should block adoption outright. The trigger for it is thin — two reports, both dated 2026-10-27, cover the same announcement: Cloudflare and VoidZero introduced tsrs, described as an AI-generated Rust port of TypeScript. That is the entire factual payload I have. No version number, no compatibility matrix, no published API surface, no artifact I could download and run.

So rather than invent a number, I'll write the article I actually want to write. Not "tsrs is fast" or "tsrs is dangerous," but: how do you decide whether a replacement TypeScript compiler is allowed near your build pipeline, and what do you measure before you decide?

My position up front: a Rust port of TypeScript is a build-speed story only if it is CLI-compatible; it is an ecosystem story only if it is API-compatible. Every announcement should be judged on which of those two it claims, and most announcements are deliberately vague about it. Wall-clock benchmarks are the easy, cheap, misleading part. Correctness diffs are the expensive part, and they are the only part that should gate adoption.

⚠️

What you will not get from this post: a tsrs speedup number. I could not confirm a canonical repository, release tag, or installable artifact from the reporting I have, so publishing a benchmark table for it would be fabrication. What you will get is the harness that produces that table on your machine, in your monorepo, with your tsconfig.json — plus the divergence classes that decide whether the number even matters.

Separating the tsrs Announcement From the Evidence

What the Public Reporting on tsrs Actually Confirms

The two reports I have agree on these points and nothing more:

  • Who: Cloudflare and VoidZero.
  • What: tsrs, described as an AI-generated Rust port of TypeScript.
  • Why it is newsworthy: it touches compiler performance and the surrounding JavaScript tooling ecosystem.

That is it. No stated parity target, no supported tsc version to compare against, no statement about whether the output is a CLI binary, a native Node addon, or both. Any article that tells you tsrs "cuts typecheck time by 60%" is either working from information I do not have or making it up — and from the outside, those two look identical.

What an AI-Generated Rust Port Does and Does Not Tell You

The phrase is a provenance fact. It is not a quality verdict in either direction, and treating it as one is how teams make bad decisions on both sides — dismissing it as slop, or trusting it because a famous logo is attached.

Four independent questions hide inside "port":

QuestionWhy it changes your decision
Which subset of the language is implemented?A CLI that handles 95% of your files fails at the 5% that gate your release
CLI parity or API parity?Determines whether your lint, test, and bundler toolchain can use it at all
Bug-for-bug compatible or "correct"?TypeScript has deliberate unsoundness; a "fixed" checker rejects code tsc accepts
Generated once, or continuously regenerated?Determines whether you can review a frozen artifact or must re-review a moving target

The last one is the one nobody asks. If the source is regenerated from a model on every merge, your review process has no stable object to review, and pinning by commit SHA stops meaning what you think it means.

Claims About tsrs I Won't Repeat Until I Can Reproduce Them

I am flagging these as unverified — not false, just not something I will state as fact:

  • Unverified: that tsrs passes the TypeScript conformance suite.
  • Unverified: that tsrs produces byte-identical or semantically identical emit for any real project.
  • Unverified: that tsrs exposes ts.createProgram, ts.LanguageService, or any part of the compiler API.
  • Unverified: any performance figure, on any workload, on any machine.

Everything below is written so that each of those becomes a red or green cell in your CI, not an argument on the internet.

Why Porting TypeScript to Rust Is Mostly a Type-Checker Problem, Not a Parser Problem

Parsing Is the Easy Half of a Compiler Rewrite

People assume a compiler's cost is parsing because parsing is the part they can picture. Run the diagnostic yourself instead of trusting the picture:

node ./node_modules/typescript/bin/tsc \
  --noEmit --extendedDiagnostics -p tsconfig.json

The output is a fixed set of labelled counters, including Parse time, Bind time, Check time, Program time, Total time, Memory used, Types, Instantiations, and I/O read. On the projects I have profiled, Check time dominates and Parse time is a single-digit percentage of the total. Do not take that on faith — the command is above, and your project's numbers are the only ones that matter for your decision.

This matters because a Rust rewrite with a beautiful zero-copy lexer and a fast parser has optimized the part that was never the bottleneck. The hard part is the checker, and the checker is hard for reasons that have nothing to do with memory safety.

Inference, Conditional Types, and Structural Assignability Are the Hard Part

The TypeScript checker is a constraint-driven inference engine wearing a type-annotation costume. The counters that expose this are Types and Instantiations — a project with heavy conditional-type or mapped-type usage will show instantiation counts in the millions for a few hundred files. Every one of those instantiations is a memoized object with identity, and the cache behavior (interning, instantiation depth limits, deferred conditional type resolution) is load-bearing for both correctness and speed.

Two things a port must reproduce that tend to get forgotten:

1. Deliberate unsoundness. TypeScript is not sound by design, and code in the wild depends on it. Two examples you can check in thirty seconds:

// void-returning function types accept any return type
type F = () => void;
const g: F = () => 42; // fine in tsc

// Method-syntax parameters stay bivariant even under strictFunctionTypes
interface A { m(x: string): void }
interface B { m(x: "a" | "b"): void }
const a: A = {} as B; // accepted by tsc; property syntax would reject this

A Rust implementation with a principled variance model rejects both. That is better type theory and worse TypeScript. If your port is stricter than tsc, you cannot drop it into an existing repo without editing code — that is a migration project, not a build-speed win.

2. Diagnostic identity. Tools do not just want a diagnostic; they want code 2322 at a specific span so a suppression comment, an ESLint disable, an expectations file, or a // @ts-expect-error keeps working. @ts-expect-error is especially brutal here: it fails the build if the error disappears. A port that is quietly more permissive breaks every @ts-expect-error in your repo — though at least it breaks them toward red CI, which is the honest failure mode.

The Programmatic API Surface Build Tools Actually Depend On

Here is the part I would push on hardest if I were reviewing tsrs as a dependency.

tsc the binary is a thin shell over the TypeScript compiler API. The ecosystem does not call the binary; it calls ts.createProgram, ts.createCompilerHost, ts.getPreEmitDiagnostics, program.getTypeChecker(), ts.createLanguageService, and — for linting — ts.Type, ts.Symbol, and checker.getTypeAtLocation(). @typescript-eslint's type-aware rules, fork-ts-checker-webpack-plugin, ts-jest in type-checking mode, vue-tsc, svelte2tsx, api-extractor, and ts-morph are all consumers of that object graph.

So the decisive question is not "is it faster than tsc." It is:

Does tsrs expose a compatible typescript module — same function names, same Type and Symbol object semantics, same diagnostic objects with file/start/length/code?

If the answer is "CLI only," you get a faster tsc invocation and nothing else. Your CI gets faster in exactly one job — the plain typecheck — while your type-aware lint, your editor, and your build plugins stay on the JavaScript implementation. That is a real but narrow win, and it is worth being precise about because the ecosystem-scale claims people make about native ports assume the API exists.

If the answer is "API-compatible," congratulations, you have taken on the hardest compatibility surface in the JavaScript tooling world: object-identity semantics that library authors rely on without documenting.

Choosing Compiler Benchmark Metrics Before You Run Anything

Wall Clock, Peak RSS, and Correctness: Pick a Primary Metric

Make correctness primary. Not because performance does not matter, but because the other two are meaningless conditional on it. A compiler that is 5× faster and rejects 0.3% of your files has a negative speedup once you count the human hours.

Order of evaluation:

  1. Correctness — diagnostic set equality and emit equivalence. Binary gate.
  2. Wall clock — median of N runs, warm, on a pinned corpus. The headline number.
  3. Peak RSS — matters for CI containers and for editor processes that live for eight hours next to your browser.

If you report only one, report correctness. If you must report a speedup, report it as a ratio with the corpus and command attached.

Corpus Selection: Why Synthetic Benchmarks Mislead

A generated corpus of 10,000 small files with no cross-file imports measures parsing and module resolution and nothing else. Real monorepos measure inference over declaration merging, generic helper libraries, and .d.ts files from @types/*.

Build a three-corpus set:

CorpusWhat it stressesWhere to get it
Your own monorepoThe only workload you actually care aboutYou already have it
A .d.ts-heavy projectskipLibCheck: false behaviour, lib file checkingAny app with a large dependency tree
A conditional-type-heavy libraryInstantiations, deferred conditional resolutionA types-heavy OSS package

Run all three. A port that wins on parsing-heavy code and loses on inference-heavy code tells you something specific and actionable; a single aggregate number tells you nothing.

Cold Start vs Warm, Single-Thread vs Parallel: Conditions You Must Pin

Define these precisely or your numbers are noise:

  • Cold process start — new process, cold page cache is impractical to control, so at minimum report warm page cache, cold process. This is the metric that matters for CLI use.
  • Warm incremental — .tsbuildinfo present and valid, one file changed. This is the metric that matters for tsc --watch and editors.
  • Single-thread vs parallel — if tsrs is multithreaded by default, comparing it to single-threaded tsc is comparing two different products. Report both, and pin the CPU count so the reader knows what the machine is.

Pin to a fixed core set so the scheduler does not decide your benchmark:

## repeatable on Linux; use `caffeinate` and a fixed performance profile on macOS
taskset -c 0-3 hyperfine --warmup 3 --runs 20 'node ./node_modules/typescript/bin/tsc --noEmit -p .'

Building a Differential Benchmark Harness Against tsc

Pinning Both Compilers and the Node Version

Everything downstream is worthless if the baseline moves. Pin exactly, and record it in the repo:

mkdir -p bench && cd bench
npm init -y
npm i -D --save-exact "typescript@$(npm view typescript version)"
node -e 'console.log("node " + process.version)' | tee versions.txt
node ./node_modules/typescript/bin/tsc --version | tee -a versions.txt
git rev-parse HEAD | tee -a versions.txt

versions.txt is not ceremony. It is the difference between a benchmark and an anecdote.

Capturing Emit and Diagnostics From the tsc Baseline

Do not shell out for the baseline. Use the API and capture emit in memory, so the emit comparison never depends on disk state:

// bench/capture.mjs




const configPath = ts.findConfigFile(process.cwd(), ts.sys.fileExists, "tsconfig.json");
if (!configPath) throw new Error("no tsconfig.json");
const { config, error } = ts.readConfigFile(configPath, ts.sys.readFile);
if (error) throw new Error(ts.flattenDiagnosticMessageText(error.messageText, "\n"));

const parsed = ts.parseJsonConfigFileContent(config, ts.sys, path.dirname(configPath), undefined, configPath);
const program = ts.createProgram({ rootNames: parsed.fileNames, options: parsed.options });

const outputs = new Map();
const emitResult = program.emit(undefined, (fileName, text) => {
  outputs.set(path.relative(process.cwd(), fileName), text);
});

const diagnostics = [...ts.getPreEmitDiagnostics(program), ...emitResult.diagnostics].map((d) => ({
  code: d.code,
  category: ts.DiagnosticCategory[d.category],
  file: d.file ? path.relative(process.cwd(), d.file.fileName) : null,
  start: d.start ?? null,
  length: d.length ?? null,
  message: ts.flattenDiagnosticMessageText(d.messageText, "\n"),
}));

diagnostics.sort((a, b) =>
  (a.file ?? "").localeCompare(b.file ?? "") || (a.start ?? -1) - (b.start ?? -1) || a.code - b.code
);

mkdirSync("bench/out", { recursive: true });
writeFileSync("bench/out/baseline-diagnostics.json", JSON.stringify(diagnostics, null, 2));
writeFileSync("bench/out/baseline-emit.json", JSON.stringify([...outputs.entries()], null, 2));
console.log(`files=${parsed.fileNames.length} emit=${outputs.size} diagnostics=${diagnostics.length}`);

Each diagnostic record looks like this — file, start, and length are what make the span comparable, and code is what makes it suppressible:

{
  "code": 2322,
  "category": "Error",
  "file": "src/config.ts",
  "start": 1184,
  "length": 6,
  "message": "Type 'string' is not assignable to type 'number'."
}

Running the Same Project Graph Through tsrs

If tsrs ships a CLI, adapt it to the same interface by shelling out and reading the emitted files from a scratch directory:

rm -rf bench/tsrs-out
tsrs --outDir bench/tsrs-out --noEmitOnError false -p tsconfig.json > bench/out/tsrs-stdout.txt 2>&1 || true

Two real limitations to note about this adapter, neither of them laziness:

  • You cannot capture in-memory emit from a CLI, so the tsrs side necessarily includes filesystem writes that the baseline avoids. That is a wall-clock advantage for tsc in the harness and a distortion you must disclose.
  • Diagnostic output parsing is lossy. tsc --pretty false prints file(line,col): error TS2322: message. Column numbers are 1-based and map to character offsets only after you account for the line table. If tsrs prints a different format, do not write a regex — use --pretty false on both and fail loudly if parsing fails, so a format change is a red build rather than a silent zero-diagnostic "pass."

If tsrs does expose the compiler API, use the same capture.mjs with the import swapped. That path is strictly better, and it is itself a useful compatibility probe: if swapping one import is all it takes, you have learned that the API surface is real.

Measuring With hyperfine and /usr/bin/time

Wall clock first, then peak RSS — they need different tools because hyperfine does not report memory:

hyperfine --warmup 3 --runs 20 --export-json bench/out/noemit.json \
  -n tsc  'node ./node_modules/typescript/bin/tsc --noEmit -p .' \
  -n tsrs 'tsrs --noEmit -p .'

--export-json writes a results array; each entry carries command, mean, stddev, median, min, max, user, system, and the raw times array. Report median and the full times array, not the mean — a mean over twenty runs where one run hit a GC pause or a background npm i is a lie with error bars.

For memory, GNU coreutils' time is the boring correct answer:

/usr/bin/time -v tsrs --noEmit -p . 2>&1 | grep -E 'Maximum resident set size|Elapsed'

The field you want is Maximum resident set size (kbytes) — peak RSS, not average, which is the right metric for a process that either fits in your CI container or gets OOM-killed. On macOS /usr/bin/time -v does not exist; use /usr/bin/time -l and read maximum resident set size, or wrap the process and poll task_info. Do not silently substitute a different metric and label it the same.

Correctness Diffs Matter More Than the Speedup

Comparing Emitted JavaScript Structurally, Not Textually

A text diff on emitted JS tells you that tsrs indents differently and nothing else. Normalize by reparsing and reprinting through the same printer:

// bench/normalize.mjs


export function normalizeEmit(fileName, text) {
  const sf = ts.createSourceFile(fileName, text, ts.ScriptTarget.ESNext, false, ts.ScriptKind.JS);
  const printer = ts.createPrinter({
    removeComments: true,
    newLine: ts.NewLineKind.LineFeed,
  });
  return printer.printFile(sf);
}

Then hash each normalized file and compare hash maps. Two caveats worth knowing before you trust this:

  • The printer preserves literal text for string literals, so 'a' vs "a" differences survive normalization. If quote style matters to your downstream tooling, walk the AST and rebuild literals instead of relying on printFile.
  • removeComments: true normalizes away comment differences — which is exactly what you want for a semantic comparison, and exactly what you must not do if your build relies on /*#__PURE__*/ annotations for tree-shaking or on /*! license blocks. Check your pinned tsc's comment retention behaviour with a two-line fixture rather than assuming it.

Comparing Diagnostic Sets by Code, File, and Span

Compare on a tuple key, not on message text:

const key = (d) => [d.code, d.file ?? "<global>", d.start ?? -1, d.length ?? -1].join("|");
const a = new Set(baseline.map(key));
const b = new Set(candidate.map(key));
const onlyBaseline = [...a].filter((k) => !b.has(k));
const onlyCandidate = [...b].filter((k) => !a.has(k));

Excluding message is deliberate: message wording changes between minor releases of tsc itself, so including it produces noise. Excluding span is not an option — a diagnostic that moved is a diagnostic your editor underlines in the wrong place and your suppression comment misses.

The Three Divergence Classes and Which One Should Block Adoption

ClassSignatureBlocks adoption?
1. CosmeticSame diagnostic set; emit differs only in whitespace, quoting, or comment retentionNo. Normalize and move on
2. Span / orderingSame set of code values, different start/length or orderingYes, for strict CI gates. Your suppressions and editor UX are span-keyed
3. SemanticDifferent code set, or emit that behaves differently at runtimeYes, unconditionally

Class 1 is where most of the noise lives and where most people stop. Class 2 is where I have seen teams get burned: the build is green because they only compared exit codes, but every @ts-expect-error in the repo silently points at the wrong line. Class 3 is the only one that should end the conversation, and it is the one you will not find without running the full corpus.

My rule: any class 3 divergence in a file your release artifacts depend on is a hard no, regardless of the speedup. A 4× faster compiler that emits subtly different class field initialization semantics is a production incident with a nice benchmark attached.

💪

When you find a class 3 divergence, minimize it before filing. Reduce to a single file, then to a single expression, then check whether the original tsc behaviour is documented or is an artifact of the checker's implementation. Half the time you will discover the "bug" is tsc being unsound on purpose, which tells you how a port should have behaved.

Reporting Benchmark Numbers You Can Defend

Environment, Command, Result: The Three-Part Format

Every number you publish needs three parts or it is not a result:

Environment: Linux 6.x, 8 vCPU (AMD EPYC), 32 GB RAM, Node v22.x, taskset -c 0-3
Command:     hyperfine --warmup 3 --runs 20 -n tsc '<baseline cmd>' -n tsrs '<candidate cmd>'
Result:      median <tsc> ms vs median <tsrs> ms over 20 runs; times array attached

Note what is missing: a single "X× faster" sentence. Derive the ratio in the reader's head from medians. Ratios computed from means of unreported variance are how benchmark tables become marketing.

Here is the table shape I would actually publish, with the cells I cannot fill left visibly empty:

Corpustsc median (ms)tsrs median (ms)ΔDiag. classes 2/3Peak RSS tsc / tsrs
This monorepoyour runpending——your run
.d.ts-heavy appyour runpending——your run
Conditional-type libyour runpending——your run

An empty cell is a finding. A filled cell I could not reproduce is a liability.

Why One Machine Is a Smoke Test, Not a Verdict

A single laptop run establishes that the tool starts and produces output. It does not establish a speedup, because:

  • Thermals and frequency scaling move the number by double-digit percentages over a 20-run series.
  • Single-thread vs parallel means a laptop with 8 cores and a 4-vCPU CI runner are different benchmarks.
  • Microarchitecture matters more for a native binary than for a JIT-warmed Node process, so the ratio is not portable across AMD/Intel/Apple Silicon.
  • Page cache and disk dominate cold .d.ts reads.

Report one machine as a smoke test explicitly labelled as such. If you need a verdict, you need at least two microarchitectures and both a laptop-class and CI-class machine.

Where a Rust Implementation Could Actually Win Against tsc

Rust is not magic, but there are three places where it has a structural advantage over a Node-hosted implementation, and they are worth naming because they tell you what to measure.

Allocation and Data Layout: Cutting GC Pressure

The TypeScript checker allocates an enormous number of short-lived objects and leans on V8's generational GC to clean up. V8 is very good at this, but "very good at GC" is still not "no GC." A Rust implementation can arena-allocate AST and type nodes, use indices instead of pointers, and drop the entire GC pressure line item. Expect the biggest wins on projects with high Instantiations counts, where the checker churns through temporary type objects. Measure it with peak RSS and with Types/Instantiations from --extendedDiagnostics, not with wall clock alone.

Parallelism and the Incremental-Build Story

tsc is largely single-threaded by design; parallelism inside the checker is hard because binding is order-dependent and declaration merging creates cross-file dependencies. A Rust rewrite that parallelizes file binding and per-file semantic checks can win on large file counts — but it must handle module augmentation, global scope merging, and declare global correctly, and those are exactly the constructs where parallel checkers get subtle and wrong.

The incremental story is a separate axis. tsc --incremental persists a .tsbuildinfo file with a format that is not a stable public contract but is shared between CI cache keys and local runs. If tsrs writes a different .tsbuildinfo format:

  • You cannot mix tsrs and tsc runs on the same cache key.
  • A cache restored from a tsc run will be silently invalidated or, worse, silently trusted.

Check this explicitly. Run tsc → tsrs → tsc with the same tsBuildInfoFile and see whether the third run reports a stale-cache rebuild. If it does, your CI cache needs a tool-scoped key, and that is a change to your pipeline, not to the compiler.

Editor and Language-Server Startup Latency

This is the win I would actually care about day to day, because it is paid on every keystroke-adjacent operation rather than once per CI job. Measure time-to-first-answer, not time-to-full-typecheck:

// bench/ls-latency.mjs — time to first completion + first quickinfo


const t0 = performance.now();
const configPath = ts.findConfigFile(process.cwd(), ts.sys.fileExists, "tsconfig.json");
const { config } = ts.readConfigFile(configPath, ts.sys.readFile);
const parsed = ts.parseJsonConfigFileContent(config, ts.sys, process.cwd(), undefined, configPath);

const host = {
  getScriptFileNames: () => parsed.fileNames,
  getScriptVersion: () => "0",
  getScriptSnapshot: (f) => {
    const text = ts.sys.readFile(f);
    return text === undefined ? undefined : ts.ScriptSnapshot.fromString(text);
  },
  getCurrentDirectory: () => process.cwd(),
  getCompilationSettings: () => parsed.options,
  getDefaultLibFileName: (o) => ts.getDefaultLibFilePath(o),
  fileExists: ts.sys.fileExists,
  readFile: ts.sys.readFile,
  readDirectory: ts.sys.readDirectory,
};

const ls = ts.createLanguageService(host, ts.createDocumentRegistry());
const target = parsed.fileNames.find((f) => f.endsWith(".ts") && !f.endsWith(".d.ts"));
const t1 = performance.now();
ls.getCompletionsAtPosition(target, 0, {});
const t2 = performance.now();
ls.getQuickInfoAtPosition(target, 0);
const t3 = performance.now();

console.log(JSON.stringify({
  programMs: Math.round(t1 - t0),
  firstCompletionMs: Math.round(t2 - t1),
  firstQuickInfoMs: Math.round(t3 - t2),
}));

Run it against typescript, then against tsrs if an API exists. programMs is a compiler question; the two first*Ms numbers are a UX question, and they are the ones users feel.

Reviewing an AI-Generated Compiler Like a Dependency

Provenance, Supply Chain, and Build Reproducibility Checks

Treat this exactly as you would treat a new native dependency with a build step — because that is what it is. A Rust crate can execute arbitrary code at build time via build.rs and procedural macros, which means cargo build is a code execution event on your CI machine.

Checklist I would run before the first CI integration:

## 1. Pin the source, not the version string
git clone <repo> && cd tsrs && git checkout <full-sha>

## 2. Cargo.lock committed, and build with it enforced
cargo build --release --locked --frozen

## 3. An offline, vendored build — proves you are not resolving at build time
cargo vendor vendor/ > .cargo/config.toml
cargo build --release --offline

## 4. Enumerate build-time code execution
grep -rl "build *=" --include=Cargo.toml . | head
ls */build.rs 2>/dev/null
cargo tree -e normal,build --prefix depth | head -50

Then the routine questions: is Cargo.lock committed? Is there a [package] license and a LICENSE file that matches it? Are dependencies vetted (cargo-deny for licenses and advisories, cargo-vet if you have an audit culture)? Is the published artifact reproducible from the pinned SHA, or is the npm tarball a different build with different flags?

On the Node side, use provenance where it exists:

npm ci
npm audit signatures   # verifies registry signatures + provenance attestations, where published
📝

None of this is specific to AI-generated code. That is the point. The provenance questions do not change because a model wrote some of the source; what changes is that "read the whole diff" stops being available as a strategy, which pushes weight onto reproducibility and differential testing.

Differential Fuzzing as the Real Acceptance Gate

You cannot code-review your way to trust in a 300,000-line compiler, AI-generated or not. You can only test your way there. The acceptance gate is a differential fuzzer: generate or mutate valid TypeScript, run both compilers, compare diagnostic fingerprints and normalized emit, and shrink any mismatch to a minimal case.

// bench/fuzz.mjs — mutation-based differential check (sketch)


const SEEDS = ["const x: number = 1;", "type T<U> = U extends string ? U : never;",
               "class C { #p = 1; get v() { return this.#p } }"];

function mutate(src) {
  const sf = ts.createSourceFile("f.ts", src, ts.ScriptTarget.ESNext, true);
  const nodes = [];
  const visit = (n) => { nodes.push(n); ts.forEachChild(n, visit); };
  visit(sf);
  const pick = nodes[Math.floor(Math.random() * nodes.length)];
  const variants = [
    () => src.slice(0, pick.getStart()) + "any" + src.slice(pick.getEnd()),
    () => src.slice(0, pick.getStart()) + "unknown" + src.slice(pick.getEnd()),
    () => src.slice(0, pick.getStart()) + src.slice(pick.getEnd()),
  ];
  return variants[Math.floor(Math.random() * variants.length)]();
}

The important design decision is the oracle. It is not "does it crash" — it is "does the diagnostic fingerprint match tsc exactly." Run thousands of iterations, bucket failures by the divergence classes above, and file the class 3 minimizations as blocking issues. A single class 3 case is worth more than a hundred thousand clean iterations.

What I Would Refuse to Ship Without

Five gates, in order. Any one missing and the answer is no:

  1. A pinned, reproducible artifact built from a pinned source SHA, with Cargo.lock enforced.
  2. A conformance report you can re-run, not a pass count quoted in a README.
  3. Differential results on your own monorepo — class 2 and class 3 counts, published, including the zeroes.
  4. A tested rollback switch, meaning you have flipped it back in a rehearsal and the build went green on tsc.
  5. Documented .tsbuildinfo compatibility, so your CI cache cannot be poisoned across tools.

A Staged Rollout Gate for Your Build Pipeline

Stage 1: Shadow Typecheck in CI With No Emit

Run both compilers, compare diagnostic fingerprints, and do not let the candidate affect the build. Fail the job on class 3 divergence; warn on class 2.

## .github/workflows/tsrs-shadow.yml
- name: shadow typecheck
  run: |
    node ./node_modules/typescript/bin/tsc --noEmit -p . --pretty false > tsc.txt 2>&1 || true
    tsrs --noEmit -p . --pretty false > tsrs.txt 2>&1 || true
    node bench/compare.mjs tsc.txt tsrs.txt

Duration: two to four weeks, or until the diff set stabilizes. Success criterion: zero class 3 divergences across every branch merged in that window.

Stage 2: Emit Diffing on a Single Leaf Package

Pick one package with no dependents inside the monorepo and no published consumers. Emit with both, hash normalized outputs, and fail the build on any hash mismatch that is not in your reviewed allow-list.

node bench/capture.mjs > /dev/null
tsrs --outDir bench/tsrs-out -p packages/leaf/tsconfig.json
node bench/compare-emit.mjs bench/emit-baseline.json bench/tsrs-out

Success criterion: the allow-list contains only class 1 entries, each with a one-line justification, and it stops growing.

Stage 3: Opt-In Emit With a Tested Rollback Switch

Only now does the candidate produce artifacts that ship. Gate it behind an environment variable so rollback is a one-line change, and rehearse the rollback before you need it:

if [ "${USE_TSRS:-0}" = "1" ]; then
  tsrs -p tsconfig.json
else
  node ./node_modules/typescript/bin/tsc -p tsconfig.json
fi

Two rules: the cache key must include the selector value (otherwise the rollback inherits a poisoned .tsbuildinfo), and someone must actually flip it back in staging and watch the build go green on the old path. An untested rollback switch is a comment with syntax.

Conclusion

The interesting thing about tsrs is not that a Rust compiler might be faster. It is that the announcement — as reported on 2026-10-27 — does not tell you which of two very different products exists: a faster tsc binary, or a reimplementation of the compiler API that the entire JavaScript tooling ecosystem sits on. Those have wildly different risk profiles, and the second is a multi-year compatibility project regardless of what language it is written in.

My recommendation is unchanged by the AI provenance angle, and that is on purpose: treat this as a native dependency with a build step, demand a pinned reproducible artifact, and let differential testing rather than testimony decide. The harness above is deliberately plain — tsc API capture, hyperfine, /usr/bin/time, and a fingerprint comparison — because the answers you need come from your monorepo, not from a launch blog post.

And if you take one thing: measure the checker, not the parser. Parsing was never the bottleneck, and a benchmark that only exercises parsing will make almost anything look like a winner.

Further Reading

Share this post

More posts

Loading comments…