TypeScript Project References

TypeScript Project References & Monorepo Builds — composite, tsc -b, Solution Configs

A lang-js-ts reference for splitting a TypeScript codebase into multiple referenced projects and building them as a graph with tsc --build. The goal: structure a monorepo (or any multi-tsconfig repo) so each package type-checks against its dependencies’ emitted .d.ts, builds in dependency order, skips up-to-date work, and gives editors cross-package “go to definition.” Defer single-project compilerOptions/strictness, the module-resolution algorithm, package-manager workspace plumbing, and bundler/task-runner orchestration to the siblings in the SKIP line.

Overview

A project reference lets one tsconfig.json declare that it depends on another via a references array. This does three things at once: it tells the editor and tsc to treat the referenced project as a prebuilt unit (consumers load its emitted .d.ts, not its source), it lets the build mode (tsc -b) order and cache compilations across the whole graph, and — with declarationMap — it keeps editor navigation jumping to the original .ts source across package boundaries.

The feature has three moving parts:

  1. composite: true on every referenced project — the opt-in that makes a project safely consumable as a dependency (forces .d.ts emit, enables incremental info, fixes the input-file set).
  2. references: [{ path }] on every consuming project — the dependency edges.
  3. tsc -b / tsc --build — a build orchestrator (distinct from the single-project tsc -p) that walks those edges topologically and uses .tsbuildinfo to skip up-to-date projects.

Version anchors (these drive “is this available?” questions):

Feature Landed in Note
composite, references, tsc -b, solution-style empty files: [] TS 3.0 the whole project-references system
incremental + .tsbuildinfo as a standalone flag TS 3.4 composite implies it
declarationMap cross-project navigation TS 3.x optional companion, recommended
disableReferencedProjectLoad, disableSolutionSearching, disableSourceOfProjectReferenceRedirect TS 3.8 large-monorepo editor-perf knobs
${configDir} template variable in extends bases TS 5.5 makes a shared base’s outDir/rootDir/paths resolve to the extending config’s dir
prepend, out deprecated (no effect from 5.5, error in 6.0) TS 5.0 → 6.0 legacy outFile bundling — do not use

tsc -b vs tsc -p in one line: tsc -p ./x type-checks/emits one project and does not build its dependencies; tsc -b ./x finds the referenced projects, checks which are out of date, and builds the out-of-date ones in dependency order first. In a referenced setup you almost always want -b.

Core Concepts

composite: true — the consumable-project contract

Setting "composite": true (default false, since TS 3.0) is mandatory on any project that appears in another project’s references. The handbook is explicit: “Referenced projects must have the new composite setting enabled.” Enabling it forces several options:

references — declaring the edges

In a consuming project:

{
  "compilerOptions": { "composite": true /* if this project is itself referenced */ },
  "references": [{ "path": "../core" }, { "path": "../utils" }]
}

Solution-style root tsconfig.json

A repo-root config that builds the whole graph but compiles nothing itself:

{
  "files": [],
  "references": [
    { "path": "packages/core" },
    { "path": "packages/utils" },
    { "path": "packages/api" }
  ]
}

The handbook’s exact guidance: “have a ‘solution’ tsconfig.json file that simply has references to all of your leaf-node projects and sets files to an empty array (otherwise the solution file will cause double compilation of files).” The empty array is legal: “starting with 3.0, it is no longer an error to have an empty files array if you have at least one reference.” tsc -b (from the repo root) then builds every package in dependency order. List all leaf projects here, not just the top-level app, or unreferenced packages won’t build.

declarationMap — cross-project go-to-definition

Without it, “go to definition” on a symbol from a referenced package lands in the generated .d.ts. With "declarationMap": true (emits .d.ts.map), “you’ll be able to use editor features like ‘Go to Definition’ and Rename to transparently navigate and edit code across project boundaries.” It is not forced by composite — it’s the recommended optional companion for any package whose source you have locally. Pair it with sourceMap for runtime debugging. (Ship the .d.ts.map and the .ts source if you want consumers of a published package to navigate too.)

Build mode internals: up-to-date checks & .tsbuildinfo

tsc -b “will: find all referenced projects, detect if they are up-to-date, build out-of-date projects in the correct order.” It decides up-to-date-ness from each project’s .tsbuildinfo file — written when incremental/composite is on, it stores “information about the project graph from the last compilation” so the next run can “detect the least number of files to re-check and re-emit.”

${configDir} (TS 5.5) — shareable bases

Before 5.5, relative paths in an extends base resolved against the base file’s location — so a shared tsconfig.base.json (especially one in node_modules) couldn’t set a useful outDir/rootDir. ${configDir} resolves to “the directory that the tsconfig is contained in” — i.e. the extending config’s dir. This makes one base reusable across every package:

// tsconfig.base.json (shared)
{
  "compilerOptions": {
    "composite": true,
    "declaration": true,
    "declarationMap": true,
    "rootDir": "${configDir}/src",
    "outDir": "${configDir}/dist",
    "tsBuildInfoFile": "${configDir}/dist/.tsbuildinfo"
  }
}

Each package’s tsconfig.json does "extends": "../../tsconfig.base.json" and outDir/rootDir land relative to that package, not the base. Requires TS 5.5+.

Tools / Frameworks

Methodology

  1. Mark every leaf package composite: true (via a shared ${configDir} base) so each emits .d.ts + .tsbuildinfo. Add declarationMap for in-repo navigation.
  2. Add references edges from each consumer to its direct dependencies (point path at the dependency’s dir/config). Keep the graph acyclic.
  3. Create a solution root with files: [] + references to all leaf packages.
  4. Build with tsc -b from the root (tsc -b --watch in dev). Never tsc -p a referenced project expecting its deps to build.
  5. In a workspace monorepo, layer the two systems: let the package manager’s symlinks resolve package names at runtime; use references for build order + types. Prefer this over pathspaths are type-only and don’t create build edges (next point).
  6. Use paths only as a fallback when you can’t rely on workspace symlinks, and remember a bundler/tsc-alias/package imports must make them work at runtime. Set them in the shared base with ${configDir}.
  7. Verify with tsc -b --dry --verbose (what would build, in what order) and tsc -b --force to rule out a stale .tsbuildinfo.

Practical Patterns

Referenced (leaf) package — packages/core/tsconfig.json:

{
  "extends": "../../tsconfig.base.json",
  "compilerOptions": {
    "composite": true,
    "declaration": true,
    "declarationMap": true,
    "rootDir": "src",
    "outDir": "dist"
  },
  "include": ["src/**/*"]
}

Consuming package that depends on it — packages/api/tsconfig.json:

{
  "extends": "../../tsconfig.base.json",
  "compilerOptions": {
    "composite": true,
    "rootDir": "src",
    "outDir": "dist"
  },
  "include": ["src/**/*"],
  "references": [{ "path": "../core" }, { "path": "../utils" }]
}

Solution root — tsconfig.json (builds the whole graph, compiles nothing itself):

{
  "files": [],
  "references": [
    { "path": "packages/utils" },
    { "path": "packages/core" },
    { "path": "packages/api" }
  ]
}

tsc -b invocations:

tsc -b                       # build the solution in ./tsconfig.json (whole graph, in order)
tsc -b --verbose             # ...and explain which projects build and why
tsc -b --watch               # incremental rebuild on change (dev loop)
tsc -b --dry --verbose       # preview the build plan without writing anything
tsc -b --clean               # delete all emitted outputs (.js/.d.ts/.tsbuildinfo)
tsc -b --force               # ignore .tsbuildinfo; rebuild everything
tsc -b packages/api          # build just api + its (out-of-date) dependencies

Workspace + references together (the recommended monorepo shape):

// package.json (pnpm/npm/yarn workspace) gives RUNTIME resolution:
//   "@scope/api" → symlink to packages/api
// tsconfig references give BUILD ORDER + TYPES.
// In packages/api/src/index.ts:
import { thing } from "@scope/core"; // resolves via workspace symlink at runtime,
                                     // and is type-checked via the ../core reference's .d.ts

Anti-Patterns

Troubleshooting

References