Project references: tsc --build, composite, and the incremental build graph
Project references split a monorepo into `tsconfig` projects that `tsc --build` compiles in dependency order. `composite` (which forces `declaration`) enables referencing and `.tsbuildinfo` caching. Traps: stale buildinfo, circular references, the 'must have composite' error.
A 200k-line monorepo took 90 seconds to type-check on every save, because one big tsconfig re-checked all packages whenever any file changed. Someone split it into referenced projects and added composite: true — and a one-line edit to the web package now re-checks only web, reusing the cached output of core and ui. The first build wrote a .tsbuildinfo per project; subsequent builds read it and skip unchanged projects. But the migration started with a wall of error TS6306: Referenced project must have setting "composite": true.
The shape: a project that points at its dependencies
Each package has its own tsconfig.json. A package that depends on another lists it under references, and the referenced project must be composite:
// packages/web/tsconfig.json
{
"compilerOptions": { "composite": true, "outDir": "dist" },
"references": [
{ "path": "../core" }, // web depends on core
{ "path": "../ui" } // and on ui
]
}// packages/core/tsconfig.json
{
"compilerOptions": {
"composite": true, // REQUIRED to be referenced
"declaration": true, // composite implies this; types are the cross-project contract
"outDir": "dist"
}
}composite: true flips on three things: it forces declaration: true (so the project emits .d.ts — the interface other projects consume instead of source), it forces incremental behaviour with a .tsbuildinfo, and it requires that every input file is covered by include/files (so the build can hash inputs reliably).
▸Why this works
Why does composite require declaration? Because the whole point of a referenced project is that downstream projects don’t re-read its source — they read its emitted .d.ts. The .d.ts is the cross-project type contract. Without declaration emit there’d be nothing for web to type-check against except core’s raw .ts, which defeats the isolation (and the speed). So composite and declaration are inseparable: a referenced project must publish its types.
tsc --build walks the reference graph
A normal tsc build of a project with references would fail or ignore them. You must use build mode: tsc --build (or tsc -b). Build mode reads the reference graph, topologically sorts it, and builds dependencies before dependents — and skips any project whose inputs are unchanged since its .tsbuildinfo:
tsc --build packages/web # builds core, then ui, then web — in dependency order
tsc --build --verbose # prints which projects are up-to-date vs rebuilt
tsc --build --clean # deletes all outputs + .tsbuildinfo (the stale-cache cure)
tsc --build --force # ignores .tsbuildinfo, rebuilds everythingIncrementality and .tsbuildinfo
Before you accept a 90-second type-check on every save, ask yourself: does your tsconfig know which files belong to each package? If not, tsc re-hashes everything every time.
.tsbuildinfo is a cache of file hashes and signatures. On rebuild, tsc -b compares current inputs to the cached state; if a project’s inputs (and the .d.ts of its dependencies) are unchanged, it’s up to date and skipped entirely. Crucially, a project only needs to rebuild a dependent when its public .d.ts actually changed — an internal-only edit that doesn’t alter the emitted types lets dependents stay cached (the “signature” optimisation).
This is also why editors feel snappier: each referenced project is type-checked in isolation, so the language server doesn’t re-analyse the whole monorepo on every keystroke.
The traps
error TS6306: Referenced project must have setting "composite": true — you referenced a project that isn’t composite. Add composite: true to the referenced one (a common confusion: people add it to the referrer).
Stale .tsbuildinfo — if you change something the cache doesn’t track (a compiler upgrade with a quirk, a hand-edited dist), the build may wrongly think a project is up to date. Cure: tsc -b --clean then rebuild, or --force.
Circular references — A references B and B references A is rejected (TS6202); a build DAG must be acyclic. Break the cycle by extracting the shared types into a third leaf project both depend on.
Path mapping across projects — paths aliases still aren’t rewritten on emit (lesson 02), so cross-project imports should go through the package’s real entry/exports, not a paths alias that only the type-checker understands.
Together, these four traps share a pattern: the error points at the referenced project, not the referrer; the cache is a file you can always delete; cycles require a new leaf to break; and aliases are a type-checker fiction that must not leak into emit.
Order what `tsc --build packages/web` does for a graph where web → ui → core and ui → core:
- 1 Read web's references, then transitively collect ui and core into the build graph
- 2 Topologically sort: core has no deps, so it builds first
- 3 For each project, hash inputs vs .tsbuildinfo; rebuild only if changed, else skip
- 4 Build dependents after their dependencies: core → ui → web, emitting .js + .d.ts
You run `tsc -b` and get TS6306: Referenced project 'packages/core' must have setting 'composite': true. Where do you add composite?
- 01What does `composite: true` do, and why does it force `declaration`?
- 02What does `tsc --build` do that plain `tsc` doesn't, and how does it use the reference graph?
- 03Name the three classic project-reference failures and their fixes.
You can now structure a monorepo for fast, isolated builds: references declare the graph, composite (forcing declaration) makes a project consumable, tsc -b orders and caches the build via .tsbuildinfo, and you know how to read TS6306, clear stale caches, and break cycles. Next, build tooling — references organise tsc’s own work, but most projects hand emit to esbuild/swc/babel; we’ll see why those transpilers don’t type-check, why isolatedModules is then mandatory, and how tsc --noEmit becomes the CI type gate alongside the bundler. Now when you see a monorepo with a single tsconfig re-checking 200k lines on every save, you know the fix: split into referenced projects, add composite: true to each leaf, and run tsc -b instead.
Practice
Start at the top. Tasks go easiest → hardest: recall a fact, apply it to a case, then a senior-level stretch. Open one, attempt it, then reveal.
Something unclear?
Ask a question about this lesson. Questions are anonymous and go straight to the author to make the lesson better.