A deep dive into tsrs

By

Understand tsrs, the Rust port of TypeScript. Try the compiler, compare benchmarks, explore its architecture, and build a browser-based type checker.

~~~

tsrs is an experimental Rust port of the TypeScript compiler. It checks your TypeScript, can produce JavaScript and declaration files, and has a WebAssembly build that runs in a browser.

Cloudflare and VoidZero announced the project on October 20, 2026. They are working with Max Schwenk of Owner.com, who created the port, in collaboration with the TypeScript team. The project is joining the Oxc toolchain.

The headline is up to 8× faster checking and roughly 70% less memory than TypeScript 7.0 at equal checker counts. Those are early vendor benchmarks, with results that depend on the workload.

There is more to understand here than a faster command. Which parts of TypeScript does tsrs replace? Why port a compiler that Microsoft already ported to Go? What does running it in a browser involve?

We’ll work through those questions, run the native compiler, and build a small browser editor that reports real TypeScript errors locally. The guide uses the project’s published documentation and experimental packages available in October 2026. The practical examples are small; the large-project performance numbers come from the project’s own measurements.

The language, compiler, and runtime

When you write TypeScript, you are dealing with several things that often get grouped together.

The language defines what your program means. It includes type annotations, generics, how types are inferred, and which assignments are allowed.

The compiler implements those rules. It loads files, understands imports, checks types, and can produce JavaScript.

The runtime executes the JavaScript. For a web page, that is the browser’s JavaScript engine. For a server, it might be Node.js or Bun.

Here is a small example:

function greet(name: string) {
  return `Hello, ${name}`
}

console.log(greet('Flavio'))

The compiler uses name: string to check calls to greet. The JavaScript runtime receives a function without that annotation.

Changing the language used to implement the compiler doesn’t mean changing the language you write. tsrs implements TypeScript’s rules in Rust. You continue writing .ts files.

It also doesn’t make your application’s JavaScript run faster by itself. A compiler finishing sooner reduces the wait while developing and building. Your application’s runtime performance is a separate measurement.

Tool or stageIts jobWhat you get
Type checkerCheck whether types agreeErrors, or a successful check
TypeScript emitTransform TypeScript into JavaScriptJavaScript, optional declarations and maps
BundlerResolve and package an application’s modulesFiles ready to deploy
JavaScript runtimeExecute the programThe application’s behavior

Some tools perform several of these jobs. Keep the jobs separate when comparing them. A tool that removes annotations quickly may still leave type checking to another command.

My TypeScript course covers the language itself. The guide to what happens when Bun runs a TypeScript file follows the runtime side of this distinction.

Where tsrs comes from

Microsoft moved its compiler from TypeScript to Go. That native implementation is the starting point for tsrs.

The compatibility document describes tsrs as a function-by-function port of the Go implementation. Its reference is upstream TypeScript’s behavior.

That means TypeScript’s maintainers continue deciding how the language works. The tsrs team has to follow those decisions and reproduce them in Rust.

Even a small difference can matter. Suppose a dependency uses a complex conditional type. If one compiler accepts it and another rejects it, the application now has a compiler compatibility problem before its own code has changed.

Error messages matter too. Editors use their locations to underline code. Build tools inspect exit statuses. Tests may compare diagnostics or generated declaration files.

The project treats differences in diagnostics, message text, and exit codes as bugs, including differences that might look preferable on their own.

The repository targets upstream development and aims for a stable release against TypeScript 7.1 with full conformance. The current package is experimental. That distinction matters when you decide whether a successful test on one project is enough to replace an existing check.

Follow a file through the compiler

A compiler does more work than removing : string from a function parameter.

It starts by reading your project configuration. include, exclude, and files help determine the root files. Imports then pull more files into the program, including declaration files from installed packages.

Next it parses the source into a structured representation. A function declaration becomes something the compiler can inspect: its name, parameters, body, and the expressions inside it.

Binding connects names to declarations. When the checker sees a call to greet, it needs to find the function that name refers to.

Checking then asks whether the argument matches the parameter. With greet(42), it finds a number where the function expects a string.

Fig 1 · 1/4What the compiler does with your code
Read the configured project

The illustration walks through the source, the relationships the compiler needs, the type error, and the possible outputs. Each scene adds information to the same example.

If emit is enabled, the compiler transforms the source and prints the output files. That can include JavaScript, declarations describing exported types, and source maps connecting generated code to the original source.

With --noEmit, you ask it to check without writing those JavaScript or declaration outputs. This fits projects where Vite or a framework already handles the application build.

There is a small distinction around incremental builds: --noEmit can still write a .tsbuildinfo cache when incremental checking is enabled. The emit documentation describes how these options interact.

Why Rust could help

A language choice gives compiler authors different ways to organize memory and parallel work. It doesn’t establish a speedup on its own.

The tsrs repository documents optimizations that go beyond translating Go syntax into Rust syntax. They include memory layout changes, checker caches, and changes to how checking work is divided between threads.

Keeping compiler data compact

A large project creates a lot of compiler objects. There are syntax nodes for the source, symbols for declarations, and types created while resolving expressions and generics.

Those objects refer to each other. The size of those references adds up across a large graph.

The project’s pointer-compression notes describe storing compact handles into an arena on supported platforms. An arena is a region of memory used to allocate a group of related objects.

Think of a compact handle as an object’s position inside that region. Storing smaller handles can reduce the space occupied by structures that contain many references.

There is work involved in turning a handle back into an address. Whether this is worthwhile depends on the memory saved and the access cost. The notes record measurements and tradeoffs, rather than treating smaller references as an automatic win.

Releasing data when it is no longer needed

Consider a test file that imports application code but isn’t imported by any other file. After checking it, the compiler may be able to release its syntax data if nothing else needs it.

The leaf-file memory notes describe this optimization for eligible files and checking modes. It has conditions because declaration merging, imports, and shared compiler state can keep data reachable.

That explains why reducing peak memory takes more care than freeing each file as soon as its own check finishes. The compiler needs proof that later work won’t need the data.

Building an optimized compiler binary

Release builds also use profile-guided optimization. The build process runs representative workloads, records which paths execute, and uses that profile when producing the compiler binary.

This helps explain why a development build from source can behave differently from a published benchmark binary. The build configuration is part of the experiment.

These techniques have their own costs and platform conditions. You can follow the Rust engineering guide for the current implementation choices. I would use project benchmarks to judge the result rather than infer performance from the word “Rust”.

Reading the benchmark charts

A compiler benchmark depends on the project, compiler revision, and number of checker threads. Changing those can change the result substantially.

Cloudflare says it worked with the TypeScript team to align the announcement’s configurations. It also notes that the newer TypeScript beta incorporates improvements, including some contributed upstream from tsrs.

The repository has a separate historical benchmark report. The chart below uses three workloads from that report. These compare TypeScript 7.0 with a tsrs build following later TypeScript development, so they are separate measurements from the announcement’s headline.

Fig 2 · 1/4How checker settings change the comparison
Equal checker counts · time

The first scenes hold the checker count at four for both compilers. The later scenes use each compiler’s defaults on that machine: four checkers for the Go compiler and sixteen for tsrs. You can compare time and peak memory under each configuration.

Both scales start at zero. Lower is better, and the chart keeps the same scale for each metric when changing the configuration.

The report used a Linux machine with 16 vCPUs and 63 GB RAM. Time is the median of ten interleaved runs for tsrs and three for the Go compiler. These are native CLI measurements. They don’t measure the browser build.

Equal threads and default settings answer different questions

An equal-checker comparison helps show what happens when the compilers have the same number of type-checking workers.

A default-settings comparison shows what users may get without tuning anything. If one compiler uses more cores, that is part of its default behavior. It also means the result includes that difference in resource use.

The distinction is visible in memory too. Giving tsrs more checkers can improve elapsed time while increasing its peak memory compared with its four-checker run.

The benchmark methodology explains the workload preparation and measurements. Here is what I would look for before quoting a result:

SettingWhy it matters
Checker threadsDefaults can give the compilers different thread counts
TypeScript revisionA language change can produce different errors on identical code
Incremental cacheA warm check does different work from a fresh check
Files and configurationExcluding files can make a compiler appear faster
Generated dependenciesMissing declarations can stop a check before the real workload
DiagnosticsA faster run must still check the intended code correctly
Build configurationOptimized release binaries and development builds can differ

Compare the errors too

For the three workloads shown here, the historical report attributes diagnostic differences to upstream changes and checks them against the matching TypeScript development build. It also records a separate tsrs bug in a sixteen-checker Drizzle ORM run: equal error counts, but different error locations.

Matching only the package names isn’t enough for a conformance comparison. A Rust port of tomorrow’s TypeScript can correctly disagree with today’s stable TypeScript.

Also, equal error counts are only an initial check. Two compilers can each report five errors while disagreeing about their locations or meanings. Compare the actual diagnostics when investigating a difference.

A speedup ratio needs a baseline

Suppose, in a hypothetical project, checking falls from 20 seconds to 5 seconds. That is a fourfold speedup and 15 seconds saved per check.

If a different project falls from 0.20 seconds to 0.05 seconds, the ratio is identical. The effect on your working day is much smaller.

For your own project, record the elapsed time of the command you run repeatedly. If you are investigating CI memory failures, peak process memory may be the more useful measurement.

Run the native compiler

We’ll create a tiny project with one type error. That gives us something concrete to check before trying tsrs on a larger codebase.

Create a folder and install the compiler locally:

mkdir tsrs-demo
cd tsrs-demo
npm init -y
npm install --save-dev tsrs

The npm package selects a native binary for supported systems. You don’t need to install Rust to use a published binary. Check the platform support table before setting it up on a different machine.

Save this configuration as tsconfig.json:

{
  "compilerOptions": {
    "strict": true,
    "target": "ESNext",
    "module": "ESNext"
  },
  "include": ["app.ts"]
}

Then save this code in app.ts:

function greet(name: string) {
  return `Hello, ${name}`
}

console.log(greet(42))

Run the check:

npx tsrs -p . --noEmit

The compiler reports that the number argument cannot be assigned to the string parameter. It exits unsuccessfully, which lets a CI job fail when the check finds an error.

Change the last line to console.log(greet('Flavio')) and run it again. The error disappears.

-p . selects the project in the current directory. --noEmit keeps this exercise focused on checking. My guide to tsconfig.json explains the configuration options.

Produce JavaScript and declaration files

To see the compiler’s other outputs, use this complete app.ts:

export function greet(name: string) {
  return `Hello, ${name}`
}

console.log(greet('Flavio'))

Now compile with declarations enabled and put the output in a separate directory:

npx tsrs -p . --outDir dist --declaration --noEmitOnError

You get dist/app.js, with the type annotation removed, and dist/app.d.ts, describing the exported function:

export declare function greet(name: string): string

The declaration describes the function’s public type. It doesn’t contain the function body.

--noEmitOnError tells the compiler to withhold outputs if it finds errors. Without that setting, reporting an error and emitting JavaScript are separate decisions.

Watch and incremental checking

For repeated work, the native CLI supports watch mode:

npx tsrs -p . --noEmit --watch

Keep the terminal open, change the argument, and save the file. The watcher starts another check. Stop it with Ctrl-C.

For separate invocations that reuse build information, you can enable incremental checking:

npx tsrs -p . --noEmit --incremental

The compiler writes build information for later runs. Keep the cache conditions consistent when benchmarking; comparing an incremental run with a fresh full check answers a different question.

The usage guide covers native watch support and platform conditions. WebAssembly has different limits, which we’ll reach shortly.

Add it to an existing project carefully

I would start by adding a separate command. Keep the existing check working while you compare the new one.

For example, these entries can sit inside an existing package.json scripts object:

{
  "typecheck": "tsc -p . --noEmit",
  "typecheck:tsrs": "tsrs -p . --noEmit"
}

Run both after the same dependency installation and code-generation steps. If your normal check generates database types first, the tsrs command needs those files too.

A successful check on an empty or incomplete input set tells you very little. Look at the files included by the configuration, particularly in a monorepo with several packages.

Project-reference builds use -b, and the compiler supports JavaScript and declaration output across referenced projects. That is another mode to test if your build depends on it. Don’t assume a successful --noEmit run has validated the declaration files you publish to npm.

Framework files need their own coverage check

An Astro component, a Vue single-file component, or a Svelte component contains more than ordinary TypeScript source.

A content mapper can present the relevant parts of those files to the compiler. The current compatibility table documents CLI support for content mappers while language-server support remains incomplete.

Keep the framework’s checking command until you have verified the same coverage. An ordinary .ts check can pass while a mistake in a component template goes unnoticed.

The editor is a separate integration

Installing tsrs in a project doesn’t automatically replace the checker your editor runs.

The native package includes a language server, started with tsrs --lsp -stdio. Editors need to launch that server through the right integration.

The editor setup guide documents VS Code’s TypeScript native extension and Neovim configuration. Follow that guide for your editor rather than pointing a legacy TypeScript server setting at a Rust executable.

Editor support has its own compatibility tests and gaps. I would compare diagnostics and navigation on the project before changing the team setup. Automatic type acquisition and content-mapper support are among the things to check in the current table.

Measure your own project

For a quick comparison, install the official compiler alongside tsrs if it isn’t already present:

npm install --save-dev typescript

Start with a clean, repeatable type-check command. On macOS or Linux, you can use the shell’s time command:

time npx tsc -p . --noEmit --incremental false --pretty false --checkers 4
time npx tsrs -p . --noEmit --incremental false --pretty false --checkers 4

These commands assume the official compiler is the native Go-based TypeScript release, which supports --checkers. An older TypeScript compiler written in JavaScript won’t accept that flag.

Run each several times and alternate their order. Close unusually heavy background jobs. The npm launcher is included in these timings, so very small projects can mostly measure startup overhead.

Record the compiler versions with npx tsc --version and npx tsrs --version. A comparison with the official stable release can help with an adoption decision. To investigate a correctness difference, compare against the exact upstream revision tsrs targets.

For a macOS peak-memory check, /usr/bin/time -l reports maximum resident set size. On Linux with GNU time, /usr/bin/time -v reports it too. The output labels and units differ, so keep them with your results.

Tune only after measuring

The native CLI exposes performance options in addition to the usual TypeScript flags:

OptionWhat it controlsTradeoff
--checkers 4Number of type-checking workersMore workers can use more memory
--singleThreadedSingle-threaded executionLower concurrency can take longer
--maxMemory 2GMemory target for --noEmit checkingRecycles checker state and can repeat work
--checkerCostCache .tsrs-costsRemember file-checking costsAdds a cache file for later scheduling

A memory target is a request to keep the process near that level. It isn’t a replacement for an operating-system memory limit or a promise that no allocation can go past it.

If an output difference seems related to checking order, the project’s defaults document explains --checkerAssignment go and --noLazyMembers. They help compare behavior with the pinned Go implementation, especially around known history and instantiation-depth cases.

I would leave those settings alone during a normal first trial. They are useful when narrowing down a specific discrepancy.

Why WebAssembly changes where the compiler can run

A native compiler binary is built for a particular operating system and processor. A WebAssembly module runs inside a host that implements the interfaces it needs.

The package @tsrs/wasm builds the shared Rust compiler code for WebAssembly. It exposes a JavaScript API for Node and browsers.

The browser API accepts an in-memory filesystem. You supply filenames and their contents, including the project configuration and dependency declarations. Standard TypeScript library declarations are bundled.

That makes a small online editor possible without running a compiler service on your server. A reader changes an example, presses a button, and gets compiler errors from their own browser.

Fig 3 · 1/5From a browser edit to a compiler diagnostic
Supply a virtual project

The scenes show the files arriving, checking inside a worker, the reported error, and the result after correcting the source. A request uses the supplied file contents; editing the textarea alone doesn’t change files in an earlier request.

If you haven’t used Wasm before, my WebAssembly tutorial explains modules, imports, and memory.

Put the compiler in a Web Worker

The browser API is asynchronous while loading the Wasm module. The compilation itself then runs synchronously in that execution context.

Calling an async function from the page doesn’t automatically move the expensive work off the UI thread. Use a Web Worker so the page can continue handling input while checking runs.

We need to create the browser worker ourselves, which is part of the example below.

Know which filesystem the compiler sees

A browser worker doesn’t receive your laptop’s project directory. It sees the virtual files you put into the request.

For one example file, that is easy. For an app importing a library, you need the relevant declarations and resolution paths too. A missing package error may mean your virtual filesystem is incomplete.

@types/node also provides declarations rather than Node’s runtime. Adding those declarations to a browser compiler would let it understand Node APIs; it wouldn’t make those APIs execute in a browser.

Compiler state and memory have limits

The package caches the compiled Wasm module and creates a fresh instance for each API call. It doesn’t keep a live TypeScript program across every keystroke.

Incremental builds can reuse files such as .tsbuildinfo. In the in-memory mode, you have to pass the written files back into the next request to reuse them. A worker staying alive doesn’t do that automatically.

The current Wasm build runs on one thread. It doesn’t support --watch, the language server, or the native API-server mode.

Each instance has at most 4 GiB of linear memory, and the host may allow less. That is a Wasm address-space limit, not a statement that every browser can comfortably allocate 4 GiB. Deep recursive types can exhaust the execution stack too.

The API’s memoryBytes measures peak linear memory, including the module’s shadow stack. It isn’t the total memory used by the browser tab. Comparing it directly with native process RSS would mix two measurements.

Cloudflare also points to Workers as a reason to pursue Rust and Wasm. A working toolchain there still needs appropriate filesystem and runtime integration. The browser demo below establishes browser support; it doesn’t test a Workers deployment.

Build a browser-based type checker

We’ll build a page with a textarea, a Check button, and a result area. The compiler will run in a dedicated worker. We won’t execute the submitted program; we only ask the compiler to check it.

Use a recent Node.js release. Create a new directory and install Vite and the Wasm package:

mkdir tsrs-browser-demo
cd tsrs-browser-demo
npm init -y
npm install --save-dev vite
npm install @tsrs/wasm

We use Vite here to bundle the worker and publish the Wasm asset at a known URL. There are three files to create.

The page

Save this complete file as index.html:

<!doctype html>
<html lang="en">
  <head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1">
    <title>TypeScript checker</title>
  </head>
  <body>
    <h1>Check TypeScript in your browser</h1>
    <label for="source">TypeScript source</label>
    <textarea id="source" rows="8" cols="50">const greeting: string = 42</textarea>
    <button id="check" type="button">Check</button>
    <pre id="result" role="status">Ready</pre>
    <script type="module" src="/main.js"></script>
  </body>
</html>

The initial program assigns a number to a string. We expect the checker to report that mistake.

The page’s JavaScript

Save this as main.js:

const source = document.querySelector('#source')
const button = document.querySelector('#check')
const output = document.querySelector('#result')

const worker = new Worker(new URL('./checker.js', import.meta.url), {
  type: 'module'
})

button.addEventListener('click', () => {
  button.disabled = true
  output.textContent = 'Checking...'
  worker.postMessage(source.value)
})

worker.addEventListener('message', ({ data }) => {
  button.disabled = false
  output.textContent = data
})

worker.addEventListener('error', () => {
  button.disabled = false
  output.textContent = 'The compiler worker failed. Reload the page to retry.'
})

The page sends the current textarea contents to the worker. Disabling the button prevents overlapping requests in this small demo.

For an editor that checks automatically while typing, you would also need to handle outdated results. This example checks only when you press the button.

The compiler worker

Save this as checker.js:

import { tsc } from '@tsrs/wasm'
import wasmUrl from '@tsrs/wasm/tsrs.wasm?url'

self.addEventListener('message', async ({ data: source }) => {
  try {
    const result = await tsc(['-p', '/project'], {
      url: wasmUrl,
      cwd: '/project',
      diagnostics: 'json',
      files: {
        '/project/tsconfig.json': JSON.stringify({
          compilerOptions: {
            strict: true,
            noEmit: true,
            target: 'ESNext',
            module: 'ESNext',
            moduleDetection: 'force'
          },
          files: ['app.ts']
        }),
        '/project/app.ts': source
      }
    })

    if (result.exitCode === 0) {
      self.postMessage('No type errors')
      return
    }

    const errors = (result.diagnostics ?? []).map((diagnostic) => {
      const line = diagnostic.startPosition
        ? diagnostic.startPosition.line + 1
        : '?'
      return `Line ${line}: TS${diagnostic.code}: ${diagnostic.text}`
    })

    self.postMessage(
      errors.join('\n') || result.stderr || result.stdout || 'Checking failed'
    )
  } catch (error) {
    self.postMessage(`Could not check the code: ${error.message}`)
  }
})

The ?url import gives us Vite’s URL for the Wasm asset. Passing it explicitly avoids depending on the asset remaining beside the bundled JavaScript file.

The virtual tsconfig.json enables strict checking and prevents output. moduleDetection: 'force' treats the file as a module, keeping its top-level declarations out of the global scope.

That last setting prevents distracting conflicts with browser globals when someone tries names such as name in the textarea. It also makes this an explicitly module-based example; remove it when you want to study global-script behavior.

The worker turns compiler diagnostics into readable text and sends them to the page. A compilation failure or Wasm-loading failure also produces a message instead of leaving the page stuck on “Checking…”.

Run it and correct the error

Start Vite:

npx vite

Open the local URL it prints and press Check. You should get TS2322, reporting that a number cannot be assigned to a string.

Change the textarea to:

const greeting: string = 'Hello'

Press Check again. The result should be No type errors.

Try an empty textarea too. It is a valid empty module, so it should also pass. That is different from a failed compiler load, which should display an error message.

To check the production bundle, run:

npx vite build
npx vite preview

Open the preview URL and repeat the checks. A development-server success alone doesn’t prove that the published worker and .wasm URLs are correct.

This demo sends source code between the page and its worker, with no type-checking server. The browser still fetches the application’s JavaScript and Wasm asset. If you extend it with remote package loading, analytics, or a backend, examine those network requests before promising that all user code stays local.

Compatibility before adoption

The project publishes a detailed compatibility table. Read the current table when you try it because support can change during the experimental period.

AreaCurrent boundaryWhat I would verify
Ordinary type checkingSupported, with known conformance exceptionsSame files and diagnostics as the matching upstream compiler
JavaScript and declarationsSupportedPublished output files and declaration consumers
Project referencesSupportedYour complete build graph
--init, --showConfigUnsupportedScripts that call these commands
Language serverAvailable natively, with documented gapsNavigation, diagnostics, and the editor integration
Framework content mappersCLI support exists; language-server support is incompleteComponents and templates, alongside .ts files
WebAssemblySingle-threaded, with separate limitsWorker behavior, asset URLs, and realistic project size

The current documentation also distinguishes supported platform packages from source builds. Check the actual package available for a target system before changing CI runners or asking every teammate to install it.

A port passing a large upstream test suite is useful evidence. It doesn’t mean every project, editor interaction, and runtime configuration has already been exercised.

How I would use tsrs

For an application with a slow type-checking step, I would add tsrs as a separate CI job first. I would let it run beside the established check across real changes, then investigate any disagreements.

I’d record which TypeScript revision each compiler follows. I would keep dependency installation, generated files, and project selection the same. If there is an error difference, I would reduce it to a small reproduction before deciding which result is correct.

If those checks agree, I would measure the time and memory saved. A smaller CI machine becoming usable could matter more than a dramatic ratio on a workload that was already fast.

For a library, I would compare generated declarations and test a consumer project. For a framework app, I would keep the framework’s own check until I could demonstrate equivalent coverage.

For teaching, I would start with the browser demo. A reader could change a union type or a generic function and see the compiler’s response beside the lesson. I would keep the example projects small enough to work on ordinary phones and laptops.

What I would avoid initially is replacing the compiler, editor integration, build output, and CI configuration in one change. Keeping those changes separate makes a discrepancy easier to locate and a rollback straightforward.

If your existing check finishes quickly and works reliably, waiting for the stable release is reasonable. The experimental package gives you a way to test the parts that matter to your project now, while keeping the current workflow available.

Want me to talk about your product? You can sponsor this site.

~~~

Related posts about typescript: