open atlas
↑ Back to track
TypeScript type system, deep TS · 07 · 04

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.

TS Middle ◷ 14 min
Level
FoundationsJuniorMiddleSenior

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 everything

Incrementality 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 referencesA 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 projectspaths 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 the steps

Order what `tsc --build packages/web` does for a graph where web → ui → core and ui → core:

  1. 1 Read web's references, then transitively collect ui and core into the build graph
  2. 2 Topologically sort: core has no deps, so it builds first
  3. 3 For each project, hash inputs vs .tsbuildinfo; rebuild only if changed, else skip
  4. 4 Build dependents after their dependencies: core → ui → web, emitting .js + .d.ts
Quiz

You run `tsc -b` and get TS6306: Referenced project 'packages/core' must have setting 'composite': true. Where do you add composite?

Recall before you leave
  1. 01
    What does `composite: true` do, and why does it force `declaration`?
  2. 02
    What does `tsc --build` do that plain `tsc` doesn't, and how does it use the reference graph?
  3. 03
    Name the three classic project-reference failures and their fixes.
Recap

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.

recallapplystretch0 of 7 done
Connected lessons

Something unclear?

Ask a question about this lesson. Questions are anonymous and go straight to the author to make the lesson better.

shortcuts expand
search
K
prev piece
k
next piece
j
cycle tier
t
this menu
?
sources1
expand
  1. 01

Trademarks belong to their respective owners. Editorial reference only.