Project references and build mode

TypeScript · Expert · 15 min

In monorepos and large apps, references are how type-checking stays fast and dependency boundaries stay enforced.

A single huge tsconfig.json makes every check and editor session load everything. Project references split the code into smaller projects with explicit dependencies.

Each referenced project sets "composite": true, which requires declaration output and that all source files are matched by include/files. A dependent project lists it under "references": [{ "path": "../core" }]. Imports across the boundary are then type-checked against the referenced project's generated `.d.ts` files rather than its sources, which is much faster and enforces that projects only depend on what they declare.

Build with `tsc --build` (tsc -b). Build mode:

  • builds referenced projects first, in dependency order;
  • skips projects that are up to date, using .tsbuildinfo files (incremental state);
  • supports --watch, --verbose (explains why each project is rebuilt), --clean and --force.

A common layout is a solution tsconfig at the root with "files": [] and references to every package, so tsc -b at the root builds everything and editors discover each project.

Practical tips:

  • Turn on declarationMap so go-to-definition jumps to the source .ts instead of the .d.ts.
  • Keep tests in their own project referencing the code under test, so production code can't import test helpers.
  • incremental: true gives single projects the .tsbuildinfo speedup without references.
  • isolatedDeclarations (TS 5.5) requires explicit types on exports, so declaration files can be produced per file by fast tools in parallel, without type-checking first.

Bundler-based monorepos often use workspace packages plus references only for type checking (emitDeclarationOnly or noEmit where allowed); the principle — small projects, explicit edges, cached builds — is the same.

Example

// tsconfig.json (solution file at the repo root)
{
  "files": [],
  "references": [{ "path": "./packages/core" }, { "path": "./packages/api" }]
}

// packages/core/tsconfig.json
{
  "compilerOptions": {
    "composite": true, "declarationMap": true,
    "rootDir": "src", "outDir": "dist", "strict": true
  },
  "include": ["src"]
}

// packages/api/tsconfig.json: depends on core
{
  "compilerOptions": { "composite": true, "rootDir": "src", "outDir": "dist", "strict": true },
  "include": ["src"],
  "references": [{ "path": "../core" }]
}

// Build everything incrementally:  npx tsc -b --verbose

Remember

  • composite: true + references split a codebase into projects
  • tsc -b builds dependencies first and skips up-to-date projects
  • Referenced projects are seen through their .d.ts output
  • Use declarationMap so navigation lands in source files

Watch out for

  • Running plain tsc -p on a project with references and expecting dependencies to be built — that needs tsc -b.

Check yourself

  1. When project api references project core, how does the checker see core's code from api?
  2. What does tsc -b do when nothing has changed since the last build?

Sign in to answer, see the explanations, and get this lesson back as a quick review later.

Official docs: www.typescriptlang.org/docs/handbook/project-references.html