Lesson 58 of 80
A single tsconfig checking a whole monorepo re-parses everything on every change. Project references split it into independently-buildable projects that only rebuild what actually changed.
15 min read
Every option explains itself — why the right one is right, and why each wrong one is wrong. Miss one and it comes back later on a spaced-repetition schedule.
This topic is about judgment and practical tradeoffs more than a single right answer — a mock interview is the better way to test yourself on it.
Be interviewed on this →Hints without spoilers, and a plain explanation of why a wrong answer was wrong — answered only from this lesson. Free account, no card.
Sign up freeWhat interviewers actually ask about this, and what a strong answer has to cover.
A strong answer covers
Likely follow-ups
Point one tsconfig.json at a monorepo with ten packages, and every tsc run type-checks all ten from scratch, every time — even if only one file in one package changed. It works, but it doesn't scale: the more packages, the slower every single edit-check cycle gets, with no way to reuse yesterday's work.
Project references split that one big project into a graph of smaller ones, each with its own tsconfig.json, each declaring which other projects it depends on. tsc -b (build mode) reads that graph, builds dependencies before dependents, and — this is the actual payoff — skips rebuilding any project whose inputs haven't changed since last time, using each project's already-emitted output instead of reparsing its source.
// packages/ui/tsconfig.json
{
"compilerOptions": {
"composite": true,
"declaration": true,
"outDir": "./dist",
"rootDir": "./src"
},
"include": ["src"]
}
// packages/app/tsconfig.json
{
"compilerOptions": {
"composite": true,
"outDir": "./dist"
},
"references": [{ "path": "../ui" }],
"include": ["src"]
}
tsc -b packages/app
# builds packages/ui first (app's declared dependency), emitting dist/*.d.ts
# then builds packages/app, which type-checks against ui's emitted .d.ts files —
# not by re-parsing ui's .ts source at all
Two things make this work. composite: true marks a project as buildable-and-referenceable — it forces declaration: true (so the project emits .d.ts files other projects can consume) and some stricter file-layout rules. references on app's config tells tsc -b the dependency order: build ui first, and when checking app's imports from ui, use ui's emitted declarations as the source of truth. Run tsc -b again after only touching a file in app, and it skips rebuilding ui entirely — its inputs haven't changed, so its last output is still valid.
composite: true requires declaration: true (it's on by default once composite is set, but can't be explicitly turned off) — a composite project that doesn't emit declarations has nothing for a dependent project to check against, so TypeScript disallows the combination outright.tsc specifically to stay fast within it..d.ts output — it's not a shortcut into the dependency's private, un-exported internals the way importing raw source might tempt you to assume.references entry doesn't necessarily error immediately. Depending on module resolution settings, an unreferenced cross-package import can still resolve via node_modules symlinks in some setups, silently skipping the ordered incremental build entirely and quietly checking against stale or absent output instead.The ambient-declarations lesson covered .d.ts files as something you write by hand to describe a library's shape. Project references use exactly the same artifact — a .d.ts file — but the compiler generates it automatically as the interface between two of your own projects: ui's emitted declarations are what app actually type-checks against, the same mechanism, just produced by tsc itself instead of a person.