TypeScript · Tooling & practice
Project references & monorepos
⚡ TL;DR — Project references split one giant
tscinvocation into a graph of independently-buildable units, each emitting a.d.ts+.tsbuildinfoso downstream packages typecheck against compiled declarations instead of re-parsing source — that's what makes incremental, cache-friendly builds possible across a monorepo.
🧠 Mental model
Without references, tsc is monolithic: it loads every file reachable from your include, holds the whole program in memory, and re-checks all of it on every run. In a 40-package monorepo that's fatal — one edit re-checks the world.
Project references turn that single program into a DAG of small programs. Each leaf compiles itself, writes a declaration file, and hands only that .d.ts to its dependents. The mental shift: a referenced project is consumed as a published library, not as source. Package B doesn't see A's implementation — it sees A's types, exactly as if A were an npm dependency you'd already built.
@app/ui ───► @app/core ───► @app/utils
│ ▲
└───────────────────────────┘
each node: tsc -b → emits .d.ts + .tsbuildinfo (its build fingerprint)
tsc walks the graph in topological order, skipping nodes whose inputs are unchanged
That .tsbuildinfo file is the whole trick: it records the hash/timestamp of every input. On the next tsc -b, unchanged nodes are skipped entirely.
⚙️ How it actually works
Three things must line up, and interviewers probe each:
-
composite: trueon every referenced project. This is the enabler flag — it forcesdeclaration: true, requires all input files to be covered byinclude/files, and makes the project emit.tsbuildinfo. Without it,tsc -brefuses to treat the project as a buildable node. -
references: [{ path: "../core" }]in the consumer's tsconfig. This is a build-graph edge, and it is separate from module resolution. A reference tellstsc -b"build core first and read its declarations"; it does not tellimportwhere to find@app/core. You still needpaths(or workspace symlinks) so the module specifier resolves. -
tsc -b(build mode), not plaintsc. Build mode is the orchestrator that reads the reference graph, topologically sorts it, checks each.tsbuildinfo, and rebuilds only stale nodes. Plaintscignoresreferencesfor orchestration.
The subtle, senior-level detail: because dependents read .d.ts, your build is only as fresh as your declarations. If A's .d.ts is stale, B typechecks against a lie. That's why tsc -b --clean and correct dependency ordering matter, and why editor "go to definition" landing in a .d.ts (not the source) is expected behaviour, not a bug.
💻 Code
Root solution file — references only, no files of its own:
// tsconfig.json (repo root) — the "solution" that builds everything
{
"files": [],
"references": [
{ "path": "packages/utils" },
{ "path": "packages/core" },
{ "path": "packages/ui" }
]
}
A shared base plus a leaf package:
// tsconfig.base.json — one source of truth for compiler options
{
"compilerOptions": {
"composite": true, // ← required for any referenced project
"declaration": true, // implied by composite, but be explicit
"declarationMap": true, // lets editors jump to SOURCE, not .d.ts
"incremental": true,
"strict": true
}
}
// packages/core/tsconfig.json
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"outDir": "dist",
"rootDir": "src",
"paths": { "@app/utils": ["../utils/src"] } // module RESOLUTION (separate concern)
},
"include": ["src"],
"references": [{ "path": "../utils" }] // build GRAPH edge
}
tsc -b # build the whole graph, skipping unchanged nodes
tsc -b --watch # incremental watch across all packages
tsc -b --clean # delete outputs + .tsbuildinfo
tsc -b --verbose # see exactly which projects were up-to-date vs rebuilt
⚖️ Trade-offs
- You get real incremental builds and enforced boundaries — package B cannot import A's internals, only its public
.d.ts. That architectural pressure is often worth more than the speed. - The cost is config sprawl and a hard invariant: every referenced project needs
composite, every edge must be declared in two places conceptually (references for build, paths/symlinks for resolution), and forgetting one produces baffling "cannot find module" or "referenced project may not disable declaration emit" errors. - When NOT to use it: a single app with no shareable libraries gains nothing but ceremony — plain
incremental: truealready gives you.tsbuildinfocaching. And if you use a bundler (Vite/esbuild/tsup) that transpiles per-file and a separatetsc --noEmitfor checking, references buy you far less; many modern monorepos runtsc -bpurely as a typecheck orchestrator and let the bundler emit JS. declarationMapis non-negotiable in practice — without it, cmd-click lands you in generated.d.tsfiles and refactoring across packages becomes miserable.
💣 Gotchas interviewers probe
- References ≠ module resolution. The most common misconception.
referencesdrivestsc -bordering; it does nothing forimport. You still needpaths,workspacessymlinks, or published packages for the specifier to resolve. State this and you sound like you've actually run a monorepo. compositeforces fullincludecoverage. Every file must be matched byinclude/files— a stray script outsidesrcthrows "file is not listed within the file list of project." Bites everyone once.- Dependents read
.d.ts, not source. So a bug fix in A that doesn't change its types still needs A rebuilt before B sees consistent behaviour at runtime — types can be fresh while emitted JS is stale if you skip the build. isolatedModulesmatters for bundlers. Per-file transpilers can't do cross-file type elision, soexport type/import typebecome mandatory to avoid emitting phantom runtime imports.- Circular references are rejected. The graph must be a DAG;
tsc -berrors on cycles. Untangling them is a real architecture task, not a config tweak. tsc -bvstsc— running plaintscin a referenced project "works" but ignores the graph and can silently typecheck against stale declarations.
🎯 Say this in the interview
"Project references let
tsctreat a monorepo as a DAG of independently-buildable projects instead of one monolithic program. Each referenced project setscomposite: true, which forces declaration emit and a.tsbuildinfofingerprint, and its dependents consume the emitted.d.tsrather than re-parsing source — sotsc -bcan topologically sort the graph and skip any node whose inputs haven't changed. The key thing I always call out is that areferenceis a build-graph edge, not module resolution — it tells build mode what order to compile in, but you still needpathsor workspace symlinks forimportto resolve. It also enforces real boundaries: a package can only see another's public declarations, never its internals. I'd pair it withdeclarationMapso editors jump to source, and in a bundler-based setup I'd often runtsc -bpurely as a typecheck orchestrator and let esbuild emit the JS."
🔗 Go deeper
- TS Handbook — Project References — the canonical spec for
composite,references, and build mode. - TS Handbook —
tsc -bbuild mode — how the orchestrator sorts and caches the graph. - TSConfig reference —
composite— exactly what the flag implies and requires. - TSConfig reference —
declarationMap— why cross-package "go to definition" needs it.