Diego Betto
Photo by Defrino Maasy on Unsplash

Diego Betto · October 9, 2026 · 15 min di lettura

Faster builds: migrating to the TypeScript 7 native compiler and Rolldown

Migration strategies for TypeScript 7 and Rolldown in a monorepo: default breaking changes, --checkers and --builders, Vite 8, tsdown, and reliable CI/CD benchmarks.

Condividi:XLinkedInFacebookWhatsApp

For a decade, the JavaScript toolchain was mostly written in JavaScript. It worked, but on a monorepo with a few hundred thousand lines, type-checking in CI took minutes and the production build took just as long. In 2026 the two slowest pieces of the pipeline were replaced by native engines: the TypeScript 7 compiler, ported to Go, and Rolldown, the Rust bundler that’s the default since Vite 8. Both promise builds 8 to 10 times faster, and sometimes more.

Promises, however, need measuring. In this article we look at how to migrate a monorepo incrementally: how to handle the default breaking changes, how to configure parallelism with --checkers and --builders without saturating your runners, and how to build a CI benchmark that actually tells you how much time and memory you saved.

First: split type-checking from building

The biggest win doesn’t come from a tool but from an architectural decision many projects have already half made: whatever checks the types isn’t what produces the JavaScript.

Task Tool in 2026 What it does
Type-checking tsc (TypeScript 7, Go) Checks types, --noEmit
App transpiling and bundling Vite 8 with Rolldown + Oxc (Rust) Strips types file by file, bundles, minifies
Library builds + .d.ts tsdown (Rolldown) or tsc --build Package JavaScript and type declarations

The bundler doesn’t read types: Oxc strips them file by file, without knowing about the rest of the program. That’s why the two steps can run in parallel as separate jobs, and a type error doesn’t have to block build feedback (or vice versa). If your build script today is tsc && vite build, this is the first change to make, before even bumping versions.

// an app's package.json
{
  "scripts": {
    "typecheck": "tsc --noEmit --checkers 4",
    "build": "vite build"
  }
}

Step 0: measure your starting point

Without a baseline you’ll never know whether the migration paid off, or what to blame for a regression. Measure three things, separately, on the same machine:

  • type-check time, cold;
  • build time, cold (no Turborepo/Nx cache, no node_modules/.vite);
  • peak memory for each process.

hyperfine is the right tool for timings, because it repeats the command and reports mean and standard deviation:

hyperfine --warmup 1 --runs 5 \
  --prepare 'rm -rf dist node_modules/.vite' \
  'npx vite build'

For memory, /usr/bin/time -v (GNU time, on Linux) reports the Maximum resident set size:

/usr/bin/time -v npx tsc --noEmit 2>&1 | grep "Maximum resident"

ℹ️ Measure cold and warm

In a monorepo with a remote cache (Turborepo, Nx), most CI builds are “warm”: untouched packages are restored from cache. Native tools pay off mostly on cache misses — a PR touching a package at the bottom of the graph, or the first build on a new branch. Measure both cases, or you risk underestimating (or overestimating) the result.

Part 1: TypeScript 7

TypeScript 7 is a port, not a rewrite: same checker, same type semantics as 6.0. What changes are the tsconfig.json defaults, the removed options, and parallelism. The details are in the TypeScript 7 deep dive; here we focus on applying them to a monorepo without stopping the team.

Strategy: 6.0 first, then 7.0

TypeScript 6.0 exists precisely as a bridge: it flags as deprecations everything that becomes an error in 7.0. The safest sequence is:

  1. upgrade the whole monorepo to 6.0 and clear every deprecation warning;
  2. turn on, in 6.0, the options that become defaults in 7.0 (stableTypeOrdering, explicit types, explicit rootDir);
  3. only then move to 7.0, which by that point should need few changes.

Default breaking changes in a monorepo

In a single project the new defaults take ten minutes to fix. In a monorepo the problem is that every package has its own tsconfig.json, often extending a shared base. It pays to pin everything in the base, explicitly, so the migration becomes a one-file change:

// tsconfig.base.json
{
  "compilerOptions": {
    "strict": true,
    "target": "es2024",
    "module": "esnext",
    "moduleResolution": "bundler",
    "types": [],
    "noUncheckedSideEffectImports": true,
    "isolatedModules": true,
    "verbatimModuleSyntax": true
  }
}
// packages/api/tsconfig.json
{
  "extends": "../../tsconfig.base.json",
  "compilerOptions": {
    "rootDir": "./src",
    "outDir": "./dist",
    "types": ["node"]
  },
  "include": ["src"]
}

The three things that break most often:

  • types: []: Node packages lose process and Buffer, test packages lose describe and expect. Each package declares its own globals.
  • rootDir: where it isn’t explicit, the dist/ layout can change (dist/src/index.js instead of dist/index.js) and break the exports in package.json. Check it package by package.
  • baseUrl removed: aliases must be rewritten as paths relative to the tsconfig.json ("@/*": ["./src/*"]).

isolatedModules and verbatimModuleSyntax aren’t required by TypeScript 7, but they guarantee every file can be transpiled on its own — meaning Oxc and Rolldown produce the same output as tsc. If you split type-checking from building, turn them on.

The bridge for tooling: TypeScript 6 alongside

TypeScript 7.0 doesn’t have a stable programmatic API yet: typescript-eslint, Volar (Vue), and the Svelte, Astro, and MDX plugins still import typescript and need 6.0 until 7.1. The fix is to keep both compilers, with an npm alias for the 6.0 package (@typescript/typescript6) as described in the deep dive: the linter uses 6.0, CI type-checking uses the native binary.

Having both installed has a side benefit: you can compare results. Before turning 6.0 off in CI, run both for a few days and diff the diagnostics:

npx tsc6 -p packages/api --noEmit --pretty false | sort > ts6.txt
npx tsc  -p packages/api --noEmit --pretty false --checkers 4 | sort > ts7.txt
diff ts6.txt ts7.txt

Sorting matters because diagnostic order can change with parallelism. The expected differences come from the new defaults and stable type ordering in messages (string | number vs number | string); anything else should be understood before you trust the new compiler.

--checkers and --builders without saturating the CPU

TypeScript 7 introduces two levels of parallelism:

  • --checkers N (default 4): how many type checkers work in parallel on files of the same project.
  • --builders N: how many referenced projects are built at once with tsc --build.

What synthetic benchmarks don’t show is that in a monorepo these two numbers multiply, and they multiply with your orchestrator’s parallelism too:

type-check threads ≈ parallel Turborepo/Nx tasks × builders × checkers

Turborepo runs up to 10 tasks in parallel by default. Ten packages each running tsc --checkers 4 means 40 checkers on a standard GitHub Actions runner with 4 vCPUs and 16 GB of RAM: the result isn’t faster, it’s slower and at risk of OOM, because each checker builds its own types and memory grows with the checker count.

Two strategies work:

A. A single tsc --build from the root, letting TypeScript manage the graph:

// tsconfig.json at the monorepo root
{
  "files": [],
  "references": [
    { "path": "packages/core" },
    { "path": "packages/ui" },
    { "path": "packages/api" },
    { "path": "apps/web" }
  ]
}
npx tsc --build --builders 2 --checkers 2

With project references, packages need composite: true and must emit declarations (emitDeclarationOnly), but you get tsc --build’s incrementality: unchanged projects aren’t re-checked.

B. The orchestrator owns parallelism, and each tsc uses little of it:

turbo run typecheck --concurrency=2
# with "typecheck": "tsc --noEmit --checkers 2" in every package

This strategy plays better with Turborepo/Nx remote caching, because each package is a separate cacheable task.

Either way the rule is the same: the product of the parallelism levels shouldn’t go far beyond the available cores. And pin the values in your scripts instead of relying on defaults: beyond performance, a different checker count can, in rare cases, produce different diagnostics, so local and CI must use the same values.

💡 GOMAXPROCS

The native compiler is a Go program, so it honors the GOMAXPROCS environment variable, which caps the number of OS threads executing Go code at once. Recent Go runtimes already read container CPU limits (cgroups), but on shared runners or with unusual limits, setting it explicitly is a simple way to cap the CPU usage of every tsc process in a job.

.d.ts files without a type checker: isolatedDeclarations

In monorepos with many internal libraries, generating .d.ts files is often the slowest part of the build, because it needs the type checker. With isolatedDeclarations: true, TypeScript requires you to explicitly annotate exported types (return types of public functions, exported constants):

// ❌ with isolatedDeclarations: the return type must be inferred from the body
export function createClient(url: string) {
  return new ApiClient(url);
}

// ✅ the type is declared, the .d.ts can be generated from this file alone
export function createClient(url: string): ApiClient {
  return new ApiClient(url);
}

In exchange, declarations can be generated file by file, without a type checker, using Oxc. That’s what tsdown does — the Rolldown-based library bundler and the natural successor to tsup:

// packages/core/tsdown.config.ts
import { defineConfig } from "tsdown";

export default defineConfig({
  entry: ["src/index.ts"],
  format: ["esm"],
  dts: true, // with isolatedDeclarations on, uses Oxc instead of the compiler
});

Type-checking stays tsc --noEmit’s job; the library build becomes independent of the checker and much faster.

Part 2: Rolldown with Vite 8

Vite 8, stable since March 2026, replaces the esbuild (dev) + Rollup (build) pair with a single bundler, Rolldown, which reached 1.0 stable in May 2026. JS/TS transforms and minification move to Oxc, and CSS is minified with Lightning CSS. The benefit isn’t only speed: dev and build finally use the same engine, so a whole class of “works in dev, breaks in build” bugs goes away.

A two-step migration

For anything beyond a trivial project, separate the bundler switch from the Vite version bump:

1. On Vite 7, try rolldown-vite (the package that previewed the integration) as a drop-in:

{
  "devDependencies": {
    "vite": "npm:[email protected]"
  }
}

If something breaks here, you know it’s Rolldown and not some other Vite 8 change.

2. Move to Vite 8 by replacing the alias with "vite": "^8.0.0" and applying the config renames.

The main configuration changes

Vite 8 includes a compatibility layer that automatically converts much of the old configuration, but it’s worth migrating explicitly so you don’t depend on deprecated options:

Vite 7 Vite 8
build.rollupOptions build.rolldownOptions
worker.rollupOptions worker.rolldownOptions
optimizeDeps.esbuildOptions optimizeDeps.rolldownOptions
esbuild (JS/TS transform) oxc
esbuild.jsx: "automatic" oxc.jsx: { runtime: "automatic" }
output.manualChunks (object) removed → output.codeSplitting
output.manualChunks (function) deprecated → output.codeSplitting
JS minification with esbuild Oxc (default)
CSS minification with esbuild Lightning CSS (default)
transformWithEsbuild (plugins) transformWithOxc

The most common case in a real app is vendor splitting. Before:

// vite.config.ts — Vite 7
export default defineConfig({
  build: {
    rollupOptions: {
      output: {
        manualChunks: {
          react: ["react", "react-dom"],
          charts: ["recharts"],
        },
      },
    },
  },
});

After, with codeSplitting groups, which support priorities and size thresholds:

// vite.config.ts — Vite 8
export default defineConfig({
  build: {
    rolldownOptions: {
      output: {
        codeSplitting: {
          groups: [
            { name: "react", test: /node_modules[\\/]react(-dom)?[\\/]/, priority: 20 },
            { name: "charts", test: /node_modules[\\/]recharts/, priority: 15 },
            { name: "vendor", test: /node_modules/, priority: 10 },
          ],
        },
      },
    },
  },
});

What can break

  • CommonJS interop: the default import from CJS modules is now handled consistently, but differently from before. If import x from "some-cjs-package" returns the wrong object, legacy.inconsistentCjsInterop: true temporarily restores the old behavior while you fix the code.
  • Plugins relying on internals: plugins using unsupported Rollup hooks (resolveImportMeta, renderDynamicImport, resolveFileUrl, shouldTransformCachedModule) or calling esbuild directly need checking one by one. esbuild is no longer a Vite dependency: if a plugin needs it, install it as a devDependency.
  • Decorators: Oxc doesn’t lower native decorators. If you use them (or legacy decorators with experimentalDecorators), you need @rolldown/plugin-babel or @rollup/plugin-swc.
  • Browser target: the default rises to Chrome/Edge 111, Firefox 114, Safari 16.4. If you support older browsers, set build.target explicitly.
  • Output formats: Rolldown doesn’t support system or amd.
  • esbuild options without an equivalent: esbuild.banner/footer need a small custom plugin; mangleProps and friends aren’t supported.

⚠ Check the bundle, not just the build

A build that finishes without errors doesn’t mean an equivalent bundle. After migrating, compare chunk count and size (for example with rollup-plugin-visualizer, which works with Rolldown), run end-to-end tests against the production build, and keep an eye on Core Web Vitals: a vendor chunk split differently can move LCP more than the faster build saves you in CI.

Part 3: a benchmark that holds up in CI

Release-post benchmarks run on very large codebases and powerful machines. What matters is your monorepo on your runner. A minimal GitHub Actions workflow, triggered manually on the migration branch:

# .github/workflows/build-benchmark.yml
name: build-benchmark
on: workflow_dispatch

jobs:
  bench:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v5
      - uses: actions/setup-node@v5
        with:
          node-version: 22
          cache: npm
      - run: npm ci
      - run: sudo apt-get update && sudo apt-get install -y hyperfine

      - name: Type-check (TS 6 vs TS 7)
        run: |
          hyperfine --warmup 1 --runs 5 --export-markdown typecheck.md \
            'npx tsc6 -b --force' \
            'npx tsc -b --force --builders 2 --checkers 2'

      - name: Peak memory
        run: |
          /usr/bin/time -v npx tsc6 -b --force 2>&1 | grep "Maximum resident" | tee -a memory.txt
          /usr/bin/time -v npx tsc  -b --force --builders 2 --checkers 2 2>&1 | grep "Maximum resident" | tee -a memory.txt

      - name: Build app (cold)
        run: |
          hyperfine --runs 3 --prepare 'rm -rf apps/web/dist apps/web/node_modules/.vite' \
            --export-markdown build.md 'npx turbo run build --filter=web --force'

      - run: cat typecheck.md build.md memory.txt >> "$GITHUB_STEP_SUMMARY"

A few tips for numbers you can trust:

  • Same runner, same job. Shared runners are noisy: comparing yesterday’s job with today’s tells you little. Run baseline and new version in the same job, back to back.
  • --force on tsc -b and on Turborepo/Nx, to rule out incremental and remote caches.
  • Repeat. Five hyperfine runs and the standard deviation tell you whether a 10% difference is real or noise.
  • Memory, not just time. Raising --checkers cuts time and increases RAM. On small runners the sweet spot is often below the default.
  • Measure total pipeline time, not just individual commands: if type-checking and building now run in parallel, the real gain is the longest job, not the sum.
  • Try several parallelism values on your typical machine (say --checkers 1, 2, 4, 8) before picking one: the curve flattens early, and past a certain point extra checkers only cost memory.

One last note on numbers: if tsc used to take 40 seconds and now takes 5, but the whole pipeline goes from 9 to 7 minutes, the bottleneck was elsewhere (dependency installs, tests, artifact uploads). It’s still a good result, but that’s the number to share with your team.

Migration checklist

  1. Split type-checking (tsc --noEmit) and building (bundler) into separate scripts and jobs.
  2. Measure the baseline: cold and warm timings, peak memory.
  3. Upgrade to TypeScript 6.0 and clear all deprecations.
  4. Make types, rootDir, module, and moduleResolution explicit in tsconfig.base.json; remove baseUrl.
  5. Install TypeScript 7 while keeping @typescript/typescript6 for the linter and plugins; diff the two compilers’ diagnostics.
  6. Pick a parallelism strategy (a single tsc --build or the orchestrator) and pin --checkers/--builders in your scripts.
  7. Evaluate isolatedDeclarations and tsdown for internal libraries.
  8. Try rolldown-vite on Vite 7, then move to Vite 8 and rename options (rolldownOptions, oxc, codeSplitting).
  9. Check plugins, CJS interop, decorators, and browser target.
  10. Compare bundles and run end-to-end tests against the production build.
  11. Repeat the benchmark in the same CI job and document time, memory, and total pipeline duration.

FAQ

❓ Should I migrate TypeScript and Vite together?

No, and it’s better not to. They’re two independent migrations: TypeScript 7 changes type-checking, Rolldown changes bundling. Doing them in separate PRs makes every regression attributable to a single cause.

❓ How many checkers should I use in CI?

It depends on the runner and on how many tasks run in parallel. On a 4-vCPU runner with a single tsc --build, the default of 4 is reasonable; if the orchestrator already runs several tasks at once, drop to 1–2 checkers per process. Measure time and memory with two or three values and pin the best one.

❓ Can I use TypeScript 7 with typescript-eslint or Vue?

Yes for CI type-checking, not (yet) for tools that use the compiler API: until TypeScript 7.1, the linter, Volar, and framework plugins need 6.0 through the @typescript/typescript6 package.

❓ Does Vite 8 type-check my code?

No, just like previous versions: Oxc strips types without checking them. Type-checking runs with tsc --noEmit in a separate script or job (or with vite-plugin-checker if you want it during development).

❓ Does Rolldown work without Vite?

Yes. Rolldown has its own CLI and a Rollup-compatible API, and it powers tsdown for libraries. For applications, though, Vite 8 remains the easiest way to adopt it.

Conclusion

Migrating to native tools is one of the few optimizations that doesn’t ask you to rewrite application code: the language stays the same, the bundle should stay equivalent, only the engines change. The real work is at the edges — config defaults, plugins, parallelism that multiplies across layers — and that’s where an incremental migration and an honest benchmark make the difference between “we bumped our dependencies” and “CI now takes half the time”.

If you want to start from what’s new in the compiler, read the introduction to TypeScript 7; for details on every removed option, see the deep dive.

References

Condividi:XLinkedInFacebookWhatsApp
Diego Betto

Written by

Diego Betto

Co-Founder & CTO at PAPION. Senior full-stack engineer specializing in React, TypeScript, Node.js, and application security.