Back to monorepo orchestration Target affected workspaces Configure turbo pipelines Speed up type-checking

Configuring tsc --build with Composite Projects

tsc --build turns TypeScript from a single-project compiler into a small build system: it reads a graph of projects connected by references, builds them in dependency order, and skips any whose inputs have not changed. To take part, each project must be composite — a flag that brings a set of rules about declarations, file lists and build info. Most errors people hit when adopting build mode come from those rules, and each rule exists for a concrete reason: other projects must be able to trust that a composite project's outputs describe exactly its inputs. This guide configures composite projects step by step, explains each rule and the error it produces when broken, and shows the commands for building, cleaning and debugging.

What composite means

Setting "composite": true promises TypeScript that the project can be consumed by others through its outputs. To keep that promise, the compiler enforces:

  • declaration is forced on, because dependents read declarations, not source.
  • Every input file must be matched by include or files. A project cannot silently pull in files from elsewhere through imports, because then its outputs would not describe a closed set of inputs.
  • rootDir defaults to the folder containing tsconfig.json, so output paths are stable.
  • Incremental build info is written (.tsbuildinfo), which is how build mode knows whether the project is up to date.

The overall model is covered in TypeScript Project References in Monorepos.

The rules a composite project must follow Four rules for composite projects: declarations are emitted, all files are listed, rootDir is stable, and build info is written; each rule maps to a specific compiler error when broken. 1 declaration: true forced on; dependents read .d.ts output 2 all files in include otherwise TS6307: file is not listed within the file list of project 3 stable rootDir output paths must not move; set rootDir explicitly 4 .tsbuildinfo records hashes of inputs so unchanged projects are skipped
Each composite rule has its own error code — knowing the rule makes the error obvious.

Configuring a library project

// packages/utils/tsconfig.json
{
  "extends": "@acme/tsconfig/library.json",
  "compilerOptions": {
    "composite": true,
    "rootDir": "src",
    "outDir": "dist",
    "tsBuildInfoFile": "dist/.tsbuildinfo",
    "declarationMap": true
  },
  "include": ["src"],
  "exclude": ["src/**/*.test.ts"]
}

And a project that depends on it:

// packages/ui/tsconfig.json
{
  "extends": "@acme/tsconfig/library.json",
  "compilerOptions": {
    "composite": true,
    "rootDir": "src",
    "outDir": "dist",
    "tsBuildInfoFile": "dist/.tsbuildinfo"
  },
  "include": ["src"],
  "exclude": ["src/**/*.test.tsx"],
  "references": [{ "path": "../utils" }]
}

Notes on each choice:

  • rootDir: "src" makes src/index.ts emit dist/index.d.ts. Without it, a stray file outside src (a config file matched by include) changes the inferred root and moves every output into dist/src/.
  • tsBuildInfoFile inside outDir means deleting dist/ also resets incremental state. Build info left behind while outputs are deleted makes TypeScript believe the project is up to date when its outputs are missing.
  • Excluding tests keeps test files out of the emitted declarations. Tests get their own configuration (below).
  • declarationMap lets editors navigate from a consumer into this project's source, as described in Publishing Declaration Maps for Go-to-Definition.

Applications and test projects

Applications are consumed by nobody, so they do not need to be composite. They reference libraries and type-check with noEmit:

// apps/web/tsconfig.json
{
  "extends": "@acme/tsconfig/app-bundler.json",
  "compilerOptions": { "noEmit": true },
  "include": ["src"],
  "references": [{ "path": "../../packages/ui" }, { "path": "../../packages/utils" }]
}

Tests are handled the same way — a separate configuration that references the library it tests and does not emit:

// packages/ui/tsconfig.test.json
{
  "extends": "./tsconfig.json",
  "compilerOptions": { "composite": false, "noEmit": true, "rootDir": "." },
  "include": ["src/**/*.test.tsx", "test"],
  "references": [{ "path": "./tsconfig.json" }]
}

Recent TypeScript versions allow noEmit leaf projects in build mode; if your version complains that a referenced or built project may not disable emit, use emitDeclarationOnly with an output folder you ignore instead.

The solution file and build commands

// tsconfig.json (root)
{
  "files": [],
  "references": [
    { "path": "packages/utils" },
    { "path": "packages/ui" },
    { "path": "packages/ui/tsconfig.test.json" },
    { "path": "apps/web" }
  ]
}
npx tsc -b                     # build everything that is out of date
npx tsc -b apps/web            # one project plus its references
npx tsc -b --verbose           # explain why each project is (not) up to date
npx tsc -b --dry               # show what would be built
npx tsc -b --force             # rebuild everything, ignoring build info
npx tsc -b --clean             # delete outputs of all projects in the graph
npx tsc -b --watch             # rebuild incrementally on change
What tsc -b does for each project For each project in reference order, tsc -b checks whether inputs or referenced outputs changed since the recorded build info, builds if needed, and writes new outputs and build info. walk references leaves first compare build info input hashes + referenced .d.ts build if stale type-check + emit write outputs .d.ts, .js, .tsbuildinfo
A project is rebuilt only when its own inputs or its references' outputs have changed.

--verbose output is the main debugging tool:

[12:04:31] Project 'packages/utils/tsconfig.json' is up to date because newest input 'packages/utils/src/format.ts' is older than output 'packages/utils/dist/.tsbuildinfo'
[12:04:31] Project 'packages/ui/tsconfig.json' is out of date because output 'packages/ui/dist/.tsbuildinfo' is older than input 'packages/ui/src/Button.tsx'
[12:04:31] Building project '/repo/packages/ui/tsconfig.json'...

Making package exports and project outputs agree

Build mode decides where declarations are written; module resolution decides where consumers look for them. They must agree, or tsc -b succeeds while every consumer's type-check fails. For a composite library with rootDir: "src" and outDir: "dist", the exports map should point types at the matching emitted file:

{
  "name": "@acme/utils",
  "exports": {
    ".": {
      "types": "./dist/index.d.ts",
      "default": "./dist/index.js"
    },
    "./format": {
      "types": "./dist/format.d.ts",
      "default": "./dist/format.js"
    }
  }
}

When a consumer project references packages/utils, TypeScript resolves @acme/utils through node_modules and the exports map to dist/index.d.ts, recognises that file as an output of a referenced project, and uses it. If the map instead pointed at src/index.ts, the compiler would map the source file back to its output — which also works, but only while outputs exist; with stale or missing outputs you get TS6305. Pointing types at dist makes the relationship explicit and matches what published consumers see.

Build mode, CI and task runners

In CI, build mode's incremental state only helps if it survives between runs. Either cache the dist/ folders (which contain declarations and build info) keyed on the lockfile and configuration, or let a task runner cache a typecheck task per package whose outputs include dist/**/*.d.ts and dist/.tsbuildinfo. The second approach also shares results with developers through a remote cache.

When a task runner orchestrates per-package tsc -b calls, keep two things consistent. The task graph must mirror the reference graph ("dependsOn": ["^typecheck"]), or a package may start checking before its references' declarations exist. And each package's task should build only itself — tsc -b without the root solution file — so the runner, not TypeScript, controls ordering and parallelism. TypeScript will still notice if a reference is out of date and rebuild it, which is harmless but duplicates work; ordering the tasks correctly avoids it.

For migrating an existing single-project tsconfig.json, start by running tsc --listFilesOnly to see every file the project currently compiles. Any file outside the package folder in that list will break the composite rules and must be moved into the package, turned into its own referenced project, or removed from the import graph.

Errors you will meet

Common build-mode errors and their fixes Maps frequent tsc -b error codes to their cause and fix. cause fix TS6307 file not listed imported file outside include add to include or move it TS6305 output not built tsc run without -b, or stale ref use tsc -b; sync references TS6306 must have composite reference to non-composite project set composite: true there TS6310 may not disable emit referenced project uses noEmit emitDeclarationOnly instead TS6059 not under rootDir file outside rootDir included fix rootDir or include
Almost every build-mode error is one of the composite rules being broken.

Once the configuration is right, these errors tend to appear only when a new file or package is added, which is another reason to generate references and check them in CI. TS6305 has its own guide: Fixing TS6305 'Output File Has Not Been Built From Source'.

Worked example: converting three packages

A team converts utils, ui and web in one afternoon. utils builds immediately. ui fails with TS6307: a story file imported a helper from ../../.storybook/decorators.tsx, outside include. They move the decorator into the package's src/test-utils (excluded from emit) and the error clears. web then fails with TS6305 because a developer runs tsc --noEmit out of habit; switching the typecheck script to tsc -b fixes it. Warm type-checks after a one-line change in ui drop from 31 seconds to 6, because utils is skipped and web reads ui's declarations instead of its source.

Prevention and guardrails

  • Set rootDir and tsBuildInfoFile explicitly in every composite project.
  • Keep tests in separate, non-emitting configurations.
  • Use tsc -b in scripts and CI, never plain tsc, for referenced projects.
  • Generate or verify references from package.json dependencies in CI.

Frequently Asked Questions

Can a composite project also be built by a bundler? Yes. Many libraries use tsc -b with emitDeclarationOnly for types and a bundler for JavaScript, writing to the same dist/ without overlapping files.

Is incremental the same as composite? No. incremental writes build info to speed up repeated builds of one project; composite includes incremental and adds the rules needed for other projects to reference it.

Does build mode support paths? Yes, but cross-package paths aliases defeat references by pointing the compiler at source. Keep paths for aliases inside a project only.

What does --clean actually delete? The outputs TypeScript knows it produced for every project in the graph — emitted JavaScript, declarations, maps and build info. It does not delete other files in outDir, such as bundler output, so a separate clean step is still useful when both write to dist/.

Why is a project rebuilt even though I changed nothing? Run tsc -b --verbose. Common reasons are a generated file inside include whose timestamp changes on every build, a referenced project whose declarations are rewritten each time, or build info stored in a folder that another tool cleans.

Can two projects share one outDir? Avoid it. Each composite project needs its own output folder and build info; sharing makes TypeScript overwrite files and misjudge which project is up to date.

Related

TypeScript Project References in Monorepos