skip to content

Project References & Incremental Builds

You will learn to split a codebase into composite projects that reference each other, build them in dependency order with tsc --build, and reuse .tsbuildinfo so unchanged projects are skipped entirely. Interviewers ask because this is TypeScript's own answer to monorepo build time and editor responsiveness.

part ofTypeScriptoverview, primer and where to startread it →
on this pageshow

questions

5

In a tsconfig.json, what does setting `"composite": true` turn on, and what does the compiler then require of that project?

level: middleimportance: must knowfreq 58%

answer

  1. a project that can be built on its own
  2. consumers read output, not sources
  3. declaration emit is not optional here
  4. the file list must be complete
  5. referenced projects inherit the same requirement

basics

~20 s

composite: true marks a tsconfig.json as a buildable unit that other projects can reference. It forces declaration on, enables incremental build info, defaults rootDir to the config's directory, and requires every input file to be matched by files or include.

solid answer

~40 s

Setting `"composite": true` declares the project a self-contained build unit in a project-references graph. Three things follow. First, `declaration` is forced on — consumers must be able to type-check against emitted `.d.ts` files rather than the project's sources, so a composite project may not turn declaration emit off. Second, `incremental` behaviour comes with it, so the project writes a `.tsbuildinfo` file that build mode uses for up-to-date checks. Third, the compiler needs a complete, static input list: `rootDir` defaults to the directory containing the tsconfig.json, and every implementation file must be matched by `files` or `include`, otherwise you get "File … is not listed within the file list of project …". Any project named in another project's `references` array must itself be composite; that is what makes the reference legal.

code

json · 10 lines
json
{
  "compilerOptions": {
    "composite": true,
    "declarationMap": true,
    "rootDir": "./src",
    "outDir": "./dist"
  },
  "include": ["src"],
  "references": [{ "path": "../core" }]
}

go deeper

for a junior

Recall that composite: true marks a project as one that other projects can reference, and that it forces declaration output on. Knowing it exists and pairs with a references array is enough at this level.

for a middle

Explain the three consequences — mandatory .d.ts emit, build-info for incremental checks, and a complete input list with rootDir defaulted — and say why each follows from the project being an independent build unit.

for a senior

Demonstrate diagnosis: interpret the not-listed-in-file-list error as an import crossing a package boundary without a reference, and describe the cost of consumers checking against generated declarations instead of source.

for a principal

Own the decision of where the project boundaries fall — how granular the graph should be, what each boundary buys in enforced isolation versus what it costs in build-info churn and cross-package change friction.

## The problem composite solves Without project references, a large TypeScript codebase is one compilation: `tsc` loads every source file reachable from the entry points and checks the lot. Every edit re-checks everything, and nothing enforces that the payments code may not reach into the internals of the auth code. Project references split that into several independently buildable compilations that know about each other. `"composite": true` is the flag that makes one tsconfig.json eligible to be such a unit — it is the producer side of the feature, while the `references` array is the consumer side. ```json { "compilerOptions": { "composite": true, "declaration": true, "declarationMap": true, "rootDir": "./src", "outDir": "./dist" }, "include": ["src"] } ``` ## What the flag turns on **Declaration emit is mandatory.** A composite project must emit `.d.ts` files, and the compiler rejects `"declaration": false` alongside `composite`. The reason is structural: when project B references project A, B does not type-check A's sources — it consumes A's emitted declarations. Those declarations are A's public contract, and without them the reference cannot be resolved. This is also why a composite project's public API must be expressible in a `.d.ts` file; a type the compiler cannot name in output produces a declaration-emit error that a non-composite project would never have hit. **Build info is produced.** Composite projects participate in incremental building: the compiler writes a `.tsbuildinfo` file recording what it compiled and with which options, which build mode later reads to decide whether the project needs rebuilding at all. `tsBuildInfoFile` moves that file if the default location is inconvenient. **The input set must be complete and static.** `rootDir` defaults to the directory containing the tsconfig.json rather than being inferred from the common ancestor of the input files, so the output layout under `outDir` is predictable. More importantly, every implementation file the compilation ends up including must be matched by `files` or `include`. If a source file is pulled in only because something imports it, but no glob covers it, you get: ``` error TS6059: File '/repo/packages/util/src/x.ts' is not listed within the file list of project '/repo/packages/api/tsconfig.json'. Projects must list all files or use an 'include' pattern. ``` That error is almost always the real diagnosis behind "composite broke my build": a project is reaching directly into another package's sources instead of going through a reference. The fix is to add a reference and import from the package, not to widen the glob until the error stops. ## The consumer side A project declares its dependencies with `references`, each entry pointing at a directory containing a tsconfig.json or at the config file itself: ```json { "references": [{ "path": "../core" }, { "path": "../logging" }] } ``` Every referenced project must be composite; otherwise the compiler reports that the referenced project must have the `composite` setting enabled. Two useful consequences follow from the reference being explicit. Imports are now checked against a declared graph, so an unreferenced package's types are simply not visible — the module boundary is enforced by the compiler rather than by convention. And the build order is derivable: build mode topologically sorts the graph rather than asking you to script it. ## What composite does not do It does not bundle, it does not link packages, and it does not change emitted JavaScript semantics — types are still erased, and the output of a composite project is the same JavaScript you would have got otherwise, plus declarations and a build-info file. It also does not resolve package names for you: the reference tells the compiler about build ordering and about which declarations back an import, while resolving `@acme/core` to a location is still ordinary module resolution — via the workspace's node_modules layout or explicit path mapping. It is also not free. Every consumer is now type-checked against generated `.d.ts` output rather than the original source, so a stale build of an upstream project produces confusing downstream errors, and a change to an upstream public type is only visible once that project has been rebuilt. That tradeoff is the reason `declarationMap` is near-mandatory alongside `composite` in a repo people edit daily. ## Reading the flag in an interview The compact way to answer: `composite` promises the compiler that this project can be built on its own and consumed as declarations. The requirements — mandatory declaration emit, a fully enumerated input set, a defaulted `rootDir`, and a build-info file — are all consequences of that one promise.

  • Why does the compiler refuse `"declaration": false` in a project that sets `"composite": true`?
    Because a composite project is consumed through its emitted `.d.ts` files. A project that references it never type-checks its sources; it reads the declarations as the public contract. With declaration emit off there would be nothing for the consumer to check against, so the combination is rejected outright rather than failing later.
  • A package errors with "File … is not listed within the file list of project …". What is usually the underlying cause?
    Something in the project imports a source file that no `files` or `include` pattern covers — typically a deep relative import reaching into a neighbouring package's `src` instead of importing the package and declaring a reference to it. Add the reference and import through the package boundary rather than widening the glob.
  • What changes about `rootDir` when a project becomes composite?
    Without `composite`, `rootDir` is inferred from the longest common directory of the input files, so adding a file outside that ancestor silently reshuffles the output layout. With `composite`, `rootDir` defaults to the directory containing the tsconfig.json, making the mapping from inputs to `outDir` stable and predictable.

saying these in an interview costs you the question

  • Thinks composite alone builds dependencies without references
  • Believes referenced projects are type-checked from their sources
  • Says composite changes the emitted JavaScript
  • Assumes a referenced project need not be composite itself
  • Widens include globs to silence the file-list error

context

open as a page

What does running `tsc --build` (`tsc -b`) do that plain `tsc -p tsconfig.json` does not?

level: middleimportance: must knowfreq 52%

basics

~20 s

Build mode walks the references graph: it builds each referenced project first, in dependency order, and skips any project whose outputs are already newer than its inputs. Plain tsc -p compiles only the one project and assumes its dependencies are already built.

open as a page

In a TypeScript project, what does the `extends` field in tsconfig.json do, and relative to which directory are relative paths written inside the inherited base config resolved?

level: juniorimportance: should knowfreq 55%

basics

~20 s

The extends field makes a tsconfig.json inherit another config file's settings, which the local file can then override key by key. Relative paths written in the base file resolve against the base file's own directory, not the inheriting project's.

open as a page

In a TypeScript repo using project references, go-to-definition on a symbol from another project lands in a generated `.d.ts` file instead of the original `.ts` source. Which compiler option addresses this, and how?

level: middleimportance: should knowfreq 42%

basics

~10 s

Enable declarationMap in the referenced project. It emits a .d.ts.map alongside each declaration file, mapping every declaration back to the source that produced it, so go-to-definition and rename follow through to the original .ts.

open as a page

A TypeScript monorepo builds with `tsc -b`, yet every CI run recompiles all projects from scratch even though only one package changed. How do you diagnose and fix that?

level: seniorimportance: should knowfreq 38%

basics

~20 s

Run tsc -b --verbose to see why each project is considered out of date. Typically CI restores neither the .tsbuildinfo files nor the emitted outputs, or a fresh checkout resets file timestamps, or a changed compiler version or config invalidates the recorded build state.

open as a page