- Book: TypeScript in Production
- Also by me: The TypeScript Library — the 5-book collection
- My project: Hermes IDE | GitHub — an IDE for developers who ship with Claude Code and other AI coding tools
- Me: xgabriel.com | GitHub
Picture a typical 22-package TypeScript monorepo. One web app, two Node services, a CLI, and the rest are shared libraries: core, auth, db, http, events, validators, plus a thin domain layer per business area. The build runs tsc -p tsconfig.json from the root with a single config that pulls every package's src/** into one compilation. Cold build on CI: in the 10-minute range. Warm build on a developer laptop after git pull: nearly as long, because the cache holds nothing useful when files moved.
The frustrating part is that most PRs touch two or three packages. The compiler does not know that. Every build re-checks the entire graph from scratch, every time. Watch mode helps a little on a single laptop, but CI cold-starts every run, and CI is what gates the merge.
The compiler does not need to be faster. It needs to know which package it is rebuilding, and which it can reuse. Project references, composite: true, tsc -b. After the migration the same cold CI build can drop into the 3-minute range. PR builds that touch one leaf package finish in well under a minute. The diff is several minutes of wall time per CI run, and CI runs on every push. Your numbers will depend on package shape, hardware, and how often you touch shared libs.
The setup is not large. It is, however, easy to get wrong in three or four small ways that each silently halve the speedup. This is the runbook.
Why a single root tsconfig is slow
The default monorepo tsconfig.json looks like this:
{
"compilerOptions": {
"module": "esnext",
"target": "es2022",
"strict": true,
"outDir": "./dist"
},
"include": ["packages/*/src/**/*"]
}
Run tsc against that and you get one program with thousands of files in it. Type-check is one big graph walk. Emit is one big pass. There is no caching boundary between packages, because the compiler does not see packages — it sees source files in directories.
The cost of a single-program model shows up in three ways. Cold builds re-walk the entire graph every time. Incremental builds are coarse: TypeScript's --incremental flag persists a .tsbuildinfo, but the granularity of "what changed" is the whole program. Editor experience suffers because the language service has to load all the files even when you are working in one corner of the repo.
The Microsoft TypeScript Performance wiki lists project references among the recommended approaches for large codebases.
What project references actually are
A project reference is a declaration in one tsconfig.json that says "this project depends on the build output of that other project." TypeScript treats each referenced project as a separate compilation unit with its own emitted declarations.
Three settings are load-bearing. composite: true on the referenced project tells tsc that this project is part of a build graph and must emit declaration files. references: [{ "path": "..." }] on the depending project tells tsc where to find the upstream. tsc -b (build mode) walks the graph in dependency order, builds only what changed, and caches the rest.
A minimal two-package example. packages/core/tsconfig.json:
{
"compilerOptions": {
"composite": true,
"declaration": true,
"declarationMap": true,
"outDir": "./dist",
"rootDir": "./src",
"module": "nodenext",
"moduleResolution": "nodenext",
"target": "es2022",
"strict": true
},
"include": ["src/**/*"]
}
packages/api/tsconfig.json:
{
"compilerOptions": {
"composite": true,
"declaration": true,
"declarationMap": true,
"outDir": "./dist",
"rootDir": "./src",
"module": "nodenext",
"moduleResolution": "nodenext",
"target": "es2022",
"strict": true
},
"include": ["src/**/*"],
"references": [
{ "path": "../core" }
]
}
Root tsconfig.json:
{
"files": [],
"references": [
{ "path": "./packages/core" },
{ "path": "./packages/api" }
]
}
tsc -b from the root walks the references, builds core first, then api. On the next run, if only api changed, core is reused from its cached build output. The compiler reads core/dist/*.d.ts instead of re-reading core/src/** and re-checking every type.
Where the wins come from
Three concrete mechanisms make project references faster than a single-program build.
Incremental builds are package-granular. A change in packages/api/src/handler.ts rebuilds api only. Every other package is reused from its .tsbuildinfo plus its emitted .d.ts files. The .tsbuildinfo records a content hash plus the dependency graph; if the inputs and the upstream signatures are unchanged, the build is a no-op.
Declaration caching is real. The downstream package consumes the upstream's .d.ts files, not its source. If core's public API has not changed (even if its internal implementation has), downstream packages do not even re-type-check against it — the signature is stable, the cache is valid.
The graph parallelises where it can. tsc -b builds packages with no inter-dependencies concurrently. With tsgo (the Go port), the parallelism extends inside individual builds too. More on that below. Microsoft has discussed the Go runtime's parallelism in the native-preview announcement as a contributor to the speedup, separate from native-code performance.
The Nx team's monorepo guide and Turborepo's writeups are clear about the same thing: caching at the package boundary is the bigger win, and it is what project references give you natively.
The setup, end to end
Walk through the migration in the order that fails least often.
Step 1: Per-package tsconfig
Each package gets its own tsconfig.json with composite: true. The settings that matter for project references are these:
{
"compilerOptions": {
"composite": true,
"declaration": true,
"declarationMap": true,
"incremental": true,
"outDir": "./dist",
"rootDir": "./src",
"tsBuildInfoFile": "./dist/.tsbuildinfo"
},
"include": ["src/**/*"]
}
composite: true implies a few things automatically: declaration: true is required (composite projects must emit .d.ts), incremental: true is on by default, and rootDir defaults to the directory of the tsconfig. Setting them explicitly is good practice: future readers see the contract.
declarationMap: true lets editors jump from a downstream consumer into the upstream .ts source rather than the emitted .d.ts. It is small extra disk for a noticeable improvement in navigation.
Step 2: Shared base config
Pull the common compiler options into a tsconfig.base.json at the repo root. Each package extends it.
{
"compilerOptions": {
"module": "nodenext",
"moduleResolution": "nodenext",
"target": "es2022",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true
}
}
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"composite": true,
"declaration": true,
"declarationMap": true,
"outDir": "./dist",
"rootDir": "./src"
},
"include": ["src/**/*"]
}
Step 3: Wire up references
In every package config, add a references array listing every package it imports from. This is mechanical: read the imports, list the upstream packages.
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"composite": true,
"declaration": true,
"declarationMap": true,
"outDir": "./dist",
"rootDir": "./src"
},
"include": ["src/**/*"],
"references": [
{ "path": "../core" },
{ "path": "../db" }
]
}
Step 4: Root tsconfig
The root is a graph manifest. No source files, just references.
{
"files": [],
"references": [
{ "path": "./packages/core" },
{ "path": "./packages/db" },
{ "path": "./packages/auth" },
{ "path": "./packages/http" },
{ "path": "./packages/api" },
{ "path": "./packages/web" },
{ "path": "./packages/cli" }
]
}
"files": [] is mandatory for a solution-style root. Without it tsc tries to compile the root as a project of its own and ignores the references.
Step 5: Build script
{
"scripts": {
"build": "tsc -b",
"build:clean": "tsc -b --clean",
"build:force": "tsc -b --force",
"build:watch": "tsc -b --watch"
}
}
tsc -b is the build-mode entry point. --clean deletes all the emitted output for a fresh start. --force rebuilds everything ignoring the cache (useful when a config changes and the cache is stale). --watch rebuilds on file change, walking the same graph but only re-running the affected nodes.
CI runs tsc -b. Local development runs tsc -b --watch in one terminal and the dev server in another.
The gotchas that erase the speedup
Five things break the model quietly.
noEmit does not compose with composite. A composite project must emit declarations. If you set noEmit: true at the base, every composite project inherits it and refuses to participate. The pattern that works: keep noEmit out of the base, set it only on the type-check-only entry point.
{
"extends": "./tsconfig.base.json",
"compilerOptions": {
"noEmit": true
},
"include": ["packages/*/src/**/*"]
}
That gives you tsc -p tsconfig.check.json for a fast no-emit type-check, separate from tsc -b for the actual build.
Every dependency must be in references. If api imports from core but api's tsconfig does not list core in its references, the build fails with a confusing error about the file not being part of any project. The fix is mechanical: every internal import maps to a reference. Missing one is the most common migration bug.
rootDir and outDir need to be inside the package. outDir: "../../dist" puts the emit outside the package and breaks reference resolution. Each package owns its own dist/.
Path aliases in paths need to point at source, not dist. If your editor experience uses paths for cross-package imports, those paths must resolve to the upstream's src/index.ts for typing, while the runtime resolution (via package.json exports or tsc-alias for emit) resolves to dist/index.js. Mixing the two breaks composite. The cleanest fix is to drop paths for cross-package imports and use real package.json workspace links.
incremental without composite is half the story. incremental: true alone gives you a .tsbuildinfo for the single program, which helps a small repo but does nothing for the package boundary. The full speedup needs composite: true per package and references wiring the graph together.
What the numbers actually look like
The 11-minutes-to-3-minutes shape on a 22-package repo is one example. Your mileage will vary with package shape, hardware, and how often you touch shared libs.
For a public benchmark on similar shapes, an independent 2025 writeup measured small (5 packages), medium (42 packages), large (217 packages), and enterprise (1,042 packages) test repos: incremental build times dropped 68 to 74 percent on the medium-and-up repos once project references plus a build cache were in place. The single-program baseline on the larger repos was minutes; the project-references-plus-cache version was tens of seconds for an incremental change.
Microsoft's own Performance wiki recommends project references as a primary tool when build time becomes a problem. There is no single headline number because the speedup is shape-dependent. The direction is consistent across every public benchmark.
Project references give you a structural win that scales with repo size. Medium repos (the 20–50 package range) see the most dramatic gains, because that is where the single-program model starts to choke. Very large repos benefit too but tend to need an external orchestrator (Turborepo, Nx, moon) on top of tsc -b for cross-machine cache sharing.
The tsc-go angle
Project references and tsc -b are stable in TypeScript 6.0. The piece that is shifting is which binary walks the graph.
The Go port (tsgo, shipped as @typescript/native-preview) supports project references and --build mode. Microsoft's native-preview announcement describes the goal of bringing --incremental, project references, and --build into the native preview, with the Go runtime contributing parallelism on top of the native-code speedup. Two speedups can stack: the per-package one from project references, and the per-build one from the Go runtime.
Concretely, the same tsc -b invocation can be replaced by tsgo -b once you install the preview:
npm install -D @typescript/native-preview@beta
{
"scripts": {
"build": "tsgo -b",
"build:watch": "tsgo -b --watch"
}
}
The --builders <n> flag tunes how many project-reference builders run in parallel. --checkers <n> tunes per-package check parallelism. Same graph, same configs, just a faster walker. Public reports from the native-preview track multi-x speedups on real codebases, with the exact ratio depending on package shape and machine.
The 2026 monorepo build path is project references plus tsgo -b. Project references give you the cache boundary; tsgo gives you the speed inside each cached step. Either alone helps. Both together is the configuration most teams will land on once tsgo reaches stable.
For now, while the preview matures, the workable pattern is tsgo for the type-check gate (tsgo -b --noEmit in CI) and tsc -b for the emit step. That mirrors the dual-binary pattern Microsoft has discussed in the native-preview announcement, applied to a graph.
What to do this week
Pick the largest tsconfig in your repo and audit it. If it pulls every package into one compilation, you are leaving most of the speedup on the floor. The migration is mechanical: per-package tsconfig.json with composite: true, a root manifest with references, and tsc -b instead of tsc. The gotchas above cover most of the things that go wrong.
Keep the old single-program config around as tsconfig.check.json for editor or full-graph type-check passes. The build path moves to tsc -b. CI gets faster. Watch mode gets cheaper. When tsgo -b is ready for your codebase, the migration is one binary swap, because the configs are already shaped the way it expects.
On a 22-package repo, dropping an 11-minute CI build to roughly three is on the order of 80 fewer minutes of CI time per developer per week, and that compounds across every PR.
If this was useful
tsc -b and project references are the difference between a monorepo that scales and one that gates every PR on a slow build. TypeScript in Production is the book that walks through this layer end to end: tsconfig shape, build orchestration, monorepo wiring, and how the pieces compose with bundlers and the runtime tools that consume your emit.
The full TypeScript Library, five books:
- TypeScript Essentials — daily-driver TS across Node, Bun, Deno, and the browser: Amazon
- The TypeScript Type System — generics, mapped/conditional types, template literals, branded types: Amazon
- Kotlin and Java to TypeScript — bridge for JVM developers: Amazon
- PHP to TypeScript — bridge for modern PHP 8+ developers: Amazon
- TypeScript in Production — tsconfig, build, monorepos, library authoring: Amazon
Books 1 and 2 are the core path. Books 3 and 4 substitute for readers coming from JVM or PHP. Book 5 is for shipping TS at work.
All five books ship in ebook, paperback, and hardcover.

Top comments (0)