Migrating to tsgo (TypeScript 7 Native Preview) in a Vite + NX Monorepo

10 min read

Decoupling type checking from Vite, moving to tsgo (the upcoming TypeScript 7 native compiler), and what the benchmarks look like across a 5,300-file NX monorepo.

  • tooling
  • dx
  • typescript
  • tsgo
  • vite
  • nx
  • monorepo

I used Claude to help draft sections of this post. The migration, the architectural decisions, and every number in the benchmark tables are mine, run on my hardware against a real workload.

The biggest app in our monorepo had a CI build that had crept past seven minutes, and a noticeable slice of that was a single-threaded tsc --noEmit running inside the Vite build target. So when Microsoft's native TypeScript compiler — a Go port, codename tsgo, intended to ship as TypeScript 7.0 — started getting regular dev releases on the @typescript/native-preview package, I had a concrete excuse to try it.

This is the story of migrating a frontend monorepo (8 NX projects, ~5,300 TypeScript files) from tsc to tsgo for type checking. The headline numbers are good — roughly 5x on a single project, around 2x end-to-end in CI. But the more interesting part is what I learned along the way: Vite doesn't type-check, the @nx/vite executor was silently shelling out to tsc on every build, and pulling that out into its own NX task turned out to be most of the win.

One prerequisite up front: this assumes the workspace is already on TypeScript 6. tsgo is built against TS 6's semantics — TypeScript 6.0 introduced the deprecations and resolution changes that align the JS-based codebase with the upcoming native one, so the two need to move in step. I covered the TS 5 to 6 upgrade for this workspace in the post the day before this one: paths cleanup, moduleResolution: "bundler", explicit rootDirs. Most of the prep work for tsgo actually landed there.

Vite Doesn't Type-Check (But Your Build Probably Does)

Vite has no type checker. It uses esbuild and Rolldown for transpilation, both of which strip types without checking them. A standalone vite build will happily compile broken TypeScript and emit valid JavaScript.

So where do the red squiggles come from when you run nx build on a Vite project? The @nx/vite:build executor wraps Vite and adds a synchronous tsc --noEmit pass before bundling, unless skipTypeCheck: true is set. That pass uses whatever typescript version is pinned in your workspace.

This is fine until your monorepo grows. Then every build pays the cost of a full type check, sequentially, on a single thread, with no caching at the NX layer because the type check is baked into the build task.

Pulling type checking out into its own NX target gives you cacheable type checks (NX hashes inputs and skips work that hasn't changed), parallel type checks (nx run-many -t typecheck fans out across projects), and a clean swap point — you can replace tsc with tsgo (or anything else) in one place per project.

Why tsgo

TypeScript 5.x and 6.x are written in TypeScript and run on Node. The same compiler has been the language's reference implementation since 2012. It works, but it's single-threaded JavaScript doing CPU-bound work.

tsgo is a native Go rewrite of the compiler that Microsoft has stated it intends to ship as TypeScript 7.0. On paper it ticks the boxes you'd want from a replacement: same diagnostics, same tsconfig.json, same flags as tsc (with a small list of intentional deviations), drop-in via the tsgo binary. The win is native code — no V8 warmup, no JIT, no GC pauses on a hot path allocating millions of AST nodes — plus parallelism, since goroutines parse and check across cores. Microsoft has cited 5–10x speedups depending on workload; my numbers came in at the lower end of that range, which is still a lot.

The "drop-in" claim mostly holds. There are a handful of cases where tsgo reports errors tsc doesn't (and vice versa) — more on that in the gotchas section.

The Migration

Step 1: Install the Native Preview

pnpm add -D @typescript/native-preview@7.0.0-dev.20260519.1

This drops a tsgo binary into node_modules/.bin. The package is platform-aware via optional dependencies (@typescript/native-preview-darwin-arm64, -linux-x64, etc.) — only the right binary for your OS/arch is installed.

I'm intentionally not pinning the exact dev version, which feels a little heretical. The standard advice for preview packages is "pin tightly." I disagree in this specific case, and I'd rather explain why than hedge.

I only use tsgo for type checking — tsgo --noEmit. Vite and Rolldown handle emit. Even if a release regressed code generation, it wouldn't affect a single byte I ship. The blast radius of a bad release is "a new diagnostic shows up on a PR," which is what a type checker is supposed to do anyway.

On top of that, the type checker is the most stable part of the project. Microsoft's stated compatibility goal is "same diagnostics as tsc," and that's where they spend their compatibility budget. Most dev-release churn is in the tooling around the compiler — LSP, build mode, watch — not the checker itself. Staying on a permissive range (^7.0.0-dev) means I pick up checker improvements continuously instead of doing a big-bang upgrade later.

If I were emitting code with tsgo, I'd pin hard. Since I'm not, I don't.

Step 2: Split Type Checking Out of the Vite Build

For every project's project.json, the build target needed two changes:

  1. skipTypeCheck: true so @nx/vite:build stops calling tsc internally.
  2. dependsOn: ["typecheck", "^build"] so NX still runs the type check first — but as a separate, cacheable task — and waits for upstream packages to build before bundling.
// Before: vite executor runs tsc internally during every build
{
  "build": {
    "executor": "@nx/vite:build",
    "outputs": ["{options.outputPath}"],
    "defaultConfiguration": "production",
    "options": {
      "outputPath": "dist/apps/web-app"
    }
  }
}
// After: typecheck is its own task; vite just bundles
{
  "build": {
    "executor": "@nx/vite:build",
    "outputs": ["{options.outputPath}"],
    "defaultConfiguration": "production",
    "dependsOn": ["typecheck", "^build"],
    "options": {
      "outputPath": "dist/apps/web-app",
      "skipTypeCheck": true
    }
  },
  "typecheck": {
    "executor": "nx:run-commands",
    "cache": true,
    "inputs": [
      "default",
      "^default",
      { "externalDependencies": ["@typescript/native-preview", "typescript"] }
    ],
    "outputs": [],
    "options": {
      "command": "tsgo --noEmit -p apps/web-app/tsconfig.app.json"
    }
  }
}

A few things worth calling out in that typecheck target. cache: true makes NX hash the inputs and skip re-running when nothing relevant has changed. The inputs array combines default and ^default so changes in this project or its dependencies invalidate the cache, plus an externalDependencies entry so a tsgo or typescript version bump also invalidates it. And outputs: [] because the type check produces no on-disk artifacts — there's nothing to restore from cache except the exit code and stdout.

A root-level shortcut runs everything in parallel:

// package.json
"scripts": {
  "typecheck": "nx run-many -t typecheck"
}

Step 3: rootDir Strictness for Cross-Project Imports (May Not Apply)

This step is the one most likely to be a no-op for you, depending on how clean your TS 6 upgrade was. TS 6 itself already enforces rootDir boundaries strictly for files resolved through paths (this is one of the things that broke our build during the 5.9 → 6 upgrade), and tsgo inherits the same posture. If your TS 6 work already cleaned up rootDir settings for libraries that import from sibling packages, tsgo will run clean. If you skipped that cleanup somehow — or you're on a workspace that hasn't moved to TS 6 yet — tsgo will surface "source file outside rootDir" errors here.

The fix, for reference, is one line in each library's tsconfig.lib.json:

{
  "extends": "./tsconfig.json",
  "compilerOptions": {
    "outDir": "../../dist/out-tsc",
    "rootDir": "../..",
    "types": ["node"]
  }
}

Pointing rootDir at the monorepo root tells the compiler "any source file inside the workspace is fair game." Same fix you'd apply to tsc once moduleResolution got stricter; tsgo just enforces it consistently.

Step 4: Verify, Then Tear Down the Old Wrapper

Once nx run-many -t typecheck returned clean — or, more honestly, returned exactly the same set of diagnostics tsc was already reporting — the skipTypeCheck: true flag in the build target was safe to leave on permanently. The Vite build is now purely bundling; the type check runs once per project, cached, in parallel.

The Numbers

Benchmarked on an M3 MacBook Pro, with NX cache and dist/out-tsc/*.tsbuildinfo cleared between cold runs. Median of three runs each.

Workspace under test: 8 NX projects, ~5,300 .ts/.tsx files, ~522K lines (raw count). Compared versions: typescript@6.0.3 vs @typescript/native-preview@7.0.0-dev.20260519.1.

Single project — the largest app

Standalone, --noEmit, against apps/web-app/tsconfig.app.json:

ToolWall timeUser CPUCPU usage
tsc 6.0.3 (cold)35.7s47.0s~140% (≈ 1.4 cores)
tsc 6.0.3 (warm, .tsbuildinfo present)6.0s7.1s~140%
tsgo 7.0-dev (cold)6.8s26.0s~440% (≈ 4.4 cores)
tsgo 7.0-dev (warm, .tsbuildinfo present)1.2s3.8s~480%

Cold-vs-cold, tsgo is ~5.3x faster. The CPU usage tells the story: tsc is bottlenecked on a single JavaScript thread, while tsgo saturates four-plus cores by parallelizing parsing and checking.

Warm-vs-warm is more interesting. tsc's incremental mode is genuinely good — it gets down to 6 seconds when the .tsbuildinfo is fresh, a 6x improvement over its own cold time. But tsgo warm is still ~5x faster than tsc warm (1.2s vs 6.0s). The multi-core advantage doesn't go away on incremental rebuilds; it stacks on top.

The most useful framing: a cold tsgo (6.8s) is roughly as fast as a warm tsc (6.0s). On a fresh CI runner with no cache, you get the same wall time tsc would only achieve after an incremental warm-up.

Full workspace — nx run-many -t typecheck

Cold (NX cache reset, .tsbuildinfo removed). All typecheck targets run in parallel:

ToolWall timeUser CPU
tsc 6.0.3 (cold)48.0s101.4s
tsc 6.0.3 (warm)21.3s65.1s
tsgo 7.0-dev (cold)13.8s45.0s
tsgo 7.0-dev (warm)10.3s42.4s

tsgo is ~3.5x faster cold and ~2x faster warm at the workspace level. The workspace speedup is smaller than the single-project number because NX was already extracting some parallelism from tsc by running multiple project type checks as separate processes — but each individual tsc process is still single-threaded, while each tsgo process uses four-plus cores. The total CPU consumed by tsgo is also less than half of tsc on cold runs, despite finishing in under 30% of the wall time.

End-to-end build pipeline

This is the number that matters in CI. The largest app's build task now depends on its own typecheck plus ^build for every upstream library:

typecheck (tsgo) → ^build (libs) → build (vite, skipTypeCheck:true)

Cold, with --skip-nx-cache:

PipelineWall timeUser CPUNotes
tsc typecheck + vite bundle (pre-migration)53.0s111.6sAll dependent tasks ran
tsgo typecheck + vite bundle (new)23.8s69.8sSame tasks; vite bundle alone: 4.2s

That's ~2.2x faster end-to-end, and tsgo also consumed ~38% less total CPU. The win isn't just wall-clock from parallelism — the native compiler does meaningfully less work per file.

On a warm CI cache (typecheck artifacts hit), the build is bottlenecked entirely on Vite; the typecheck task returns from cache in milliseconds.

Where the speedup comes from

The biggest contributor is native code. Go compiles to a static binary, so there's no V8 warmup, no JIT, no garbage collector pauses on a hot path that allocates millions of small AST nodes. Multi-threading is the second-biggest factor: tsgo parses files in parallel across goroutines, then runs the binder and checker across multiple cores. tsc was always single-threaded by design.

The third factor, which I'd undervalued going in, is just the NX caching layer. Pulling typecheck out of the build means NX can hash inputs and skip the work entirely on unchanged projects. That's a free win you get even if you stay on tsc, and it pairs naturally with the migration.

Gotchas

A few things bit me during the rollout.

tsgo is intentionally compatible with tsc but not byte-identical. I saw a handful of cases where it reported errors tsc didn't (three across two of our library packages), and one case where tsc flagged a generic variance issue in some table library types that tsgo accepted. None were wrong — all were real type issues — but be prepared to either fix them or pin the older typechecker until you have time. The TypeScript team documents the intentional differences in the "Known and Notable Differences" section of the Native Previews announcement, and unintentional divergences can be reported on the typescript-go issue tracker.

rootDir strictness is the other big behavior difference, covered in step 3. If your monorepo has cross-project source imports via paths, expect to point rootDir at the workspace root.

Editor support is separate. This migration only swaps the build and CI typechecker — VS Code still uses the typescript package via the TS language server. There's an experimental TypeScript (Native Preview) VS Code extension that wires tsgo up as an LSP language server, but it wasn't production-ready when I rolled this out (auto-imports, find-all-references, and rename were still missing). So you get fast CI type checks, but your in-editor IntelliSense remains on the old compiler until the native LSP catches up.

.tsbuildinfo interop is mostly fine. tsgo and tsc write incremental info in similar but not identical formats, so a stale file from an older tsc will force the first tsgo run to rebuild from scratch. Out of caution I cleared dist/out-tsc/*.tsbuildinfo before the first rollout — two minutes of work, and I didn't hit any related issues after that. Worth doing as a precaution even if I can't say for certain it was strictly necessary.

And don't forget @nx/vite:build's skipTypeCheck flag. If you miss it on a project, you'll be type-checking twice — once via the new typecheck task and once via the vite executor's internal tsc. The wall-clock difference is dramatic; if your "fast" build still feels slow, this is the first thing to check.

Why Decouple, Even Without tsgo

The interesting takeaway isn't just "tsgo is fast." Type checking should be its own NX task regardless of which typechecker you use. The coupling between build and type check inside @nx/vite:build predates NX's caching story for non-built targets, and it actively prevents NX from helping you skip work that hasn't changed.

Once typecheck is its own target with cache: true and proper inputs, you get free skips on unchanged projects (the common case in iterative development), per-project parallelism in CI, and a clean swap point if you ever want to change the typechecker again. The mental model gets simpler too: the build bundles, the typecheck checks types.

What I'd Do Differently

If I were starting over, I'd split this into two PRs. The first would decouple typecheck from the Vite build target while keeping tsc as the checker. That alone gives you cacheable, parallel type checks, and it would have made the later tsctsgo switch a single-line change per project. Doing both at once meant that the first time a new error showed up in CI, I had to spend a moment ruling out whether it was a tsgo behavior difference or a side effect of the build refactor before I could chase the actual fix.

Conclusion

The diff was small — roughly one new typecheck target per project, plus a skipTypeCheck: true and a dependsOn in each existing build target. The actual work was adding the typecheck task to every project (an hour or two), flipping skipTypeCheck and adding dependsOn (mechanical), and triaging the new diagnostics tsgo reported but tsc had missed — most of a day, mostly because the team needed time to agree on the fixes rather than because the fixes themselves were hard. The harder structural work — paths, moduleResolution, rootDir — was already done as part of the TypeScript 6 upgrade, which is what made this swap as light as it was.

What I got: ~5x faster single-project type checks, ~3.5x faster full-workspace type checks, ~2x faster end-to-end builds on cold CI cache, and effectively instant type checks on warm NX cache for unchanged projects.

@typescript/native-preview is still a preview, and editor tooling lags the CLI. But for CI builds, where you control the version and the inputs, tsgo is good enough today to be a real productivity win. The day TypeScript 7.0 ships stable, this migration becomes a single dependency bump (@typescript/native-previewtypescript@7.0.0) and a tsgotsc rename in the typecheck commands.

If you haven't moved to TypeScript 6 yet, that's the prerequisite — I wrote about how I handled it for this workspace here.