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:
declarationis forced on, because dependents read declarations, not source.- Every input file must be matched by
includeorfiles. A project cannot silently pull in files from elsewhere through imports, because then its outputs would not describe a closed set of inputs. rootDirdefaults to the folder containingtsconfig.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.
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"makessrc/index.tsemitdist/index.d.ts. Without it, a stray file outsidesrc(a config file matched byinclude) changes the inferred root and moves every output intodist/src/.tsBuildInfoFileinsideoutDirmeans deletingdist/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).
declarationMaplets 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
--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
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
rootDirandtsBuildInfoFileexplicitly in every composite project. - Keep tests in separate, non-emitting configurations.
- Use
tsc -bin scripts and CI, never plaintsc, for referenced projects. - Generate or verify
referencesfrompackage.jsondependencies 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 explains the overall model.
- Fixing TS6305 'Output File Has Not Been Built From Source' resolves the most frequent build-mode error.
- Speeding Up Type-Checking in Large Monorepos tunes build mode for scale.
- Sharing a Base tsconfig Across Workspaces provides the presets these projects extend.