Skip to content

How to Migrate a TypeScript 7 Monorepo with tsc --build

Migrate a multi-package TypeScript 7 monorepo with project references, composite builds, and tsc --build, plus a CI cache for .tsbuildinfo files.

· · 8 min read

Updated: September 30, 2026

Two monitors side by side showing code

Quick Take

Migrating a TypeScript 7 monorepo means turning on project references and composite builds, then running tsc --build so the native compiler builds packages in dependency order and skips unchanged ones. Done right, a change to one package rebuilds only that package and the ones downstream of it.

Run this yourself. The four-package composite workspace from this guide, as files that build: typescript-7-monorepo/ in the Coding Dunia code-examples repo. Its test asserts the build order, the per-package cache and the failure mode rather than describing them.

Here's the short version. To move a multi-package workspace onto the TypeScript 7 native compiler, you give each package a composite tsconfig, wire the packages together with project references, and switch your build command to tsc --build (build mode). If you want the background on why the compiler got 8x-12x faster, the Project Corsa hub covers the Go rewrite. This guide is only about the monorepo plumbing.

The whole migration comes down to four moves, in this order:

  1. Add composite: true and incremental: true to a shared base tsconfig
  2. Give every package a references list pointing at its dependencies
  3. Add a root tsconfig with "files": [] that references all packages
  4. Replace tsc with tsc --build in your scripts and CI

Why does a monorepo need project references?

Project references are a TypeScript configuration feature that lets one package declare a dependency on another inside the compiler's own build graph, so the compiler can type-check and build packages in the correct order instead of re-scanning the whole workspace on every run. A single app can upgrade by bumping one typescript dependency and running tsc. A monorepo can't, at least not well. When package ui imports package core, the compiler has to type-check core first, then ui. Without project references, tsc re-reads and re-checks every file in the graph on every run. That's the slow CI job nobody likes.

Project references fix the ordering problem. You tell each package which siblings it depends on, mark those packages as composite, and the compiler builds a proper dependency graph. Build mode then walks that graph, compiles packages bottom-up, and writes a .tsbuildinfo file per package so the next run skips anything that didn't change.

To see the mechanism rather than take it on faith, I built the four-package workspace from this guide (core, config, ui, api) and ran tsc --build with typescript@7.0.2 on Node 22. The first build took about 0.8 seconds and an immediate second run about 0.3. After I edited config, --verbose reported core and ui as up to date and rebuilt only config and api. The fixture is tiny, so its timings say nothing about your repo; the rebuild scope is the point. For raw compiler speed on real code, see the tsgo benchmarks. The type system itself didn't change at all, generics, strict mode, and conditional types behave exactly like they did on TS 5. Only the compiler underneath got replaced.

The type system itself didn't change at all. Only the compiler underneath got replaced, which is why this migration is a tsconfig job and not a rewrite.

Share this Post on X Bluesky

What goes in the shared base tsconfig?

Start with one tsconfig.base.json at the repo root. Every package extends it, so module settings stay identical across the workspace. This is where the TS 7 breaking changes bite: moduleResolution: node10 is gone, and so is classic resolution. Pick bundler when a bundler owns your final output, or nodenext when Node resolves your packages directly.

{
  "compilerOptions": {
    "target": "ES2023",
    "module": "esnext",
    "moduleResolution": "bundler",
    "composite": true,
    "incremental": true,
    "declaration": true,
    "declarationMap": true,
    "strict": true,
    "skipLibCheck": true
  }
}

composite: true is the line that matters. It forces declaration output and lets other packages reference this one. incremental gives you the per-package .tsbuildinfo cache. Did you notice there's no outDir here? That belongs in each package's own tsconfig, not the shared base, because every package writes to its own folder. A build cache in this context is the set of .tsbuildinfo files the compiler writes per package, recording which inputs produced which outputs so a later run can skip work whose inputs haven't changed. Not sure your own base config is clean of the removed options first? Run it through the TypeScript 7 Migration Readiness Checker or the tsconfig.json generator before you split it across packages.

A shared base tsconfig with composite: true and incremental: true forces declaration output and enables the per-package .tsbuildinfo cache, and every package in the monorepo should extend that one base file rather than duplicating module settings.

Share this Post on X Bluesky
A long warehouse aisle flanked by tall shelves of neatly stored boxes
Photo by Lance Chang on Unsplash

How do you wire the packages together?

Each package gets a tiny tsconfig that extends the base and lists its own dependencies under references. A root tsconfig references every package but compiles nothing itself. It's just the entry point for build mode.

{
  "files": [],
  "references": [
    { "path": "packages/core" },
    { "path": "packages/config" },
    { "path": "packages/ui" },
    { "path": "packages/api" },
    { "path": "apps/web" }
  ]
}

The "files": [] line is easy to miss. It tells the compiler this config owns no source of its own, it only orchestrates. Order in the array doesn't decide build order; the compiler reads each package's own references and works out the real graph. Forget composite: true in a referenced package and you'll see TS6306: Referenced project ... must have setting "composite": true. Disable declaration output in a composite package and you'll get TS6304: Composite projects may not disable declaration emit.

How does tsc --build actually run?

Build mode is one command. Point it at the root and it builds everything the graph requires, in order, skipping what's already current.

# Build the whole workspace, bottom-up, incremental
tsc --build tsconfig.json

# Force a clean rebuild when .tsbuildinfo gets stale
tsc --build --clean && tsc --build --verbose

# Watch mode across every referenced package
tsc --build --watch

The first run is the slow one, because there's no cache yet. Every run after that reads the .tsbuildinfo files and rebuilds only the packages whose inputs changed. Touch a type in core and build mode rebuilds core plus everything downstream, and it leaves untouched packages alone. That selective behavior is the whole point, and it's why I'd argue build mode matters more in a monorepo than the raw compiler speed does.

Running loose scripts across the workspace? Node's native TypeScript execution pairs nicely here, with no separate transpile step (see running .ts without ts-node).

How do you keep CI from rebuilding everything?

The trick is to cache the .tsbuildinfo files between runs. Restore them at the start of the job, run tsc --build, and only the packages that changed since the last green build actually recompile.

- uses: actions/cache@v4
  with:
    path: |
      **/*.tsbuildinfo
    key: tsbuild-${{ github.sha }}
    restore-keys: tsbuild-

- run: tsc --build tsconfig.json --verbose

In the fixture above, editing the leaf config package rebuilt two of the four projects and left the other two alone; with the .tsbuildinfo files restored from cache, CI gets the same behavior. Is that setup worth it? For a team merging many PRs a day, I'd say yes. The cache turns build mode into a de facto affected-only build without any extra tooling.

Caching the .tsbuildinfo files between CI runs turns tsc --build into a de facto affected-only pipeline, so a single-package PR check rebuilds only the changed package and its dependents, without any extra tooling beyond the standard actions/cache step.

Share this Post on X Bluesky
A heap of cardboard boxes stacked in the middle of a large warehouse
Photo by Getty Images on Unsplash

What Changed Before and After the Migration?

Before (no project references)After (composite + tsc --build)
Full-repo type-checkEvery file, every runFirst run full, later runs skip current packages
Single-package PR check (CI, cached)Full rebuildChanged package plus dependents
Rebuild scope on a changeEntire graph re-checkedOnly the changed package plus downstream dependents
moduleResolutionnode10 or classic allowedBoth removed, hard error; must use bundler or nodenext
Required tsconfig flagNot neededcomposite: true on every referenced package

What trips people up during the migration?

Three things, and I'd rather you hit them in a branch than in CI. First, any package with moduleResolution: node10 buried in a local override is a hard error in TS 7, not a warning. The general TS 7 migration guide walks through those removed flags if you're coming straight from 5.x. Next, a package that sets declaration: false to speed up an old build breaks under composite, which needs declarations (TS6304, as above), so that flag has to go. Last, packages that depend on template type-checking, such as Vue, can't fully use the native compiler at GA because they need a stable compiler API. Keep those on TS 6 and build them on their own. Mixed-version monorepos are fine, as long as the boundary stays clean.

Check Your Own tsconfig.json

Paste it below for an instant readiness score against everything TypeScript 7 removed or changed. Runs in your browser, nothing is uploaded.

Frequently Asked Questions

Do I have to use project references for a TypeScript 7 monorepo?
For a real multi-package workspace, yes. Project references let the native compiler build packages in dependency order and cache each one separately. Without them, tsc re-checks the entire graph on every run, which throws away most of the speed the Go compiler gives you. A single app can skip references, but a monorepo should not.
What does composite: true actually do?
It marks a package as referenceable by other packages and forces declaration output so consumers get types. It also enables the per-package .tsbuildinfo cache that build mode uses to skip unchanged work. If a referenced package is missing composite, you get a TS6306 error and the build stops.
Which moduleResolution should the shared base tsconfig use in TS 7?
Use bundler when a bundler owns your final output, or nodenext when Node resolves your packages directly. The old node10 mode and classic resolution were removed in TypeScript 7, so any package still using them is a hard error, not a warning. Set the value once in the base config and let every package extend it.
Can a monorepo mix TypeScript 6 and TypeScript 7 packages?
Yes, as long as the boundary is clean. Frameworks like Vue, Svelte, Astro, and Angular template type-checking cannot fully use TS 7 at GA because they need a stable programmatic compiler API. Keep those packages on TS 6 and build them separately, while React, Node, and plain TypeScript packages move to 7.