Composite Builds for Multi-Package Repos
Use TypeScript composite builds and tsc --build to incrementally compile interdependent packages in a monorepo with cached, dependency-ordered output.
A ten-package monorepo where changing one file in a leaf package triggers a full recompile of every other package — even ones with no dependency on the file that changed — wastes minutes on every local build and every CI run. The fix is TypeScript’s composite build mode: setting "composite": true and building with tsc --build instead of a plain tsc invocation gives the compiler a persistent, per-package cache and a dependency graph it can use to skip work that is already up to date. This extends the reference wiring described in tsconfig Project References for Monorepos — references establish the graph, composite builds are what make walking that graph fast.
Root Cause
A plain tsc invocation has no concept of “this project” versus “the projects it depends on” — it type-checks and emits every file matched by include in one pass, every time, with no persistent record of what it did last time (unless bare incremental: true is set, which caches within a single project but has no cross-project awareness). tsc --build layers a build orchestrator on top: for every project in the references graph, it writes a .tsbuildinfo file recording source file hashes, compiler options, and emitted output. On the next build, it compares each project’s current inputs against that snapshot and only recompiles projects whose inputs — or whose dependencies’ outputs — actually changed. Without composite: true, TypeScript cannot generate this cross-project snapshot at all, because composite is what enables the declaration-map guarantees the graph needs to trust one project’s output as another’s input.
Minimal Reproduction
A three-package chain (core → utils → app) built with plain tsc inside each package directory, driven by a root script:
{
"scripts": {
"build": "tsc -p packages/core && tsc -p packages/utils && tsc -p packages/app"
}
}
Every invocation of build recompiles all three packages in full, regardless of which one actually changed, because each tsc -p call is an independent process with no shared cache and no knowledge of the others.
Full Rebuild vs Incremental --build
Step-by-Step Fix
Step 1 — Set composite: true and a stable tsBuildInfoFile per package
// packages/core/tsconfig.json
{
"compilerOptions": {
"composite": true,
"declaration": true,
"declarationMap": true,
"outDir": "./dist",
"rootDir": "./src",
"tsBuildInfoFile": "./dist/.tsbuildinfo"
},
"include": ["src/**/*.ts"]
}
Placing tsBuildInfoFile inside dist/ (rather than the default location next to tsconfig.json) keeps it alongside the other build output your CI cache already tracks.
Step 2 — Replace the sequential script with a root solution build
// tsconfig.json — repository root
{
"files": [],
"references": [
{ "path": "packages/core" },
{ "path": "packages/utils" },
{ "path": "packages/app" }
]
}
{
"scripts": {
- "build": "tsc -p packages/core && tsc -p packages/utils && tsc -p packages/app"
+ "build": "tsc --build"
}
}
Expected output on a clean checkout (full build, nothing cached yet):
Building project '/repo/packages/core/tsconfig.json'...
Building project '/repo/packages/utils/tsconfig.json'...
Building project '/repo/packages/app/tsconfig.json'...
Step 3 — Confirm incremental skipping on an unrelated change
Touch a file only in packages/app/src/ and rebuild:
touch packages/app/src/index.ts
tsc --build --verbose
Expected output:
Project 'packages/core/tsconfig.json' is up to date with .d.ts files from its dependencies
Project 'packages/utils/tsconfig.json' is up to date with .d.ts files from its dependencies
Project 'packages/app/tsconfig.json' is out of date because output 'dist/index.js' is older than input 'src/index.ts'
Building project '/repo/packages/app/tsconfig.json'...
Only app rebuilds — core and utils are skipped entirely because their inputs and the .d.ts files they depend on are unchanged.
HAZARD PREVENTION
Symptom: Every
tsc --buildrun recompiles the entire graph, even for a change isolated to one leaf package.Root cause:
.tsbuildinfofiles were deleted between runs (commonly because they were listed in.gitignoreand excluded from the CI cache key, so CI never persists them across runs), oroutDirbetween two packages resolves to the same physical directory, causing one package’s emit to invalidate another’s snapshot.Fix: Cache each package’s
.tsbuildinfoexplicitly in CI (packages/*/dist/.tsbuildinfo), keyed on a lockfile hash, and confirm every package has a distinct, non-overlappingoutDir.
Step 4 — Add a force-clean path for CI release builds
Incremental caching is correct for local development, but a release build should not trust a cache that might not reflect what will actually be published. Force a clean rebuild before packing:
tsc --build --clean
tsc --build --verbose
Wire this into the prepublishOnly step described in Publishing Packages from a pnpm Workspace so every publish reflects a true, from-scratch compilation.
Verification
# Time a full rebuild
time tsc --build --clean && time tsc --build
# Time a no-op rebuild — should be near-instant and print nothing
time tsc --build
A correctly cached repository shows the third command completing in well under a second with no output, compared to several seconds (scaling with package count) for the clean build.
# Confirm .tsbuildinfo exists for every composite package
find packages -name ".tsbuildinfo" | wc -l
# Should equal the number of composite packages in the graph
Edge Cases / Gotchas
incrementalvscomposite.incremental: truealone caches within a single project across repeated runs, but has no cross-project graph — it does not helptsc --buildunderstand dependency ordering.compositeimpliesincrementaland adds the cross-project contract.tsc --build --force. Use this to bypass the cache entirely when you suspect it is stale or corrupted, without deleting.tsbuildinfofiles by hand.- Watch mode across the graph.
tsc --build --watchrecompiles only the affected subgraph on each file change, in dependency order — far more efficient during local development than onetsc --watchprocess per package. - Docker layer caching. If
.tsbuildinfofiles are generated inside a build stage that gets discarded, every container build performs a full recompile; copy the cache directory forward into later stages if build speed matters in CI. - Declaration-only packages still need
outDir. Even a package with no runtime code, only shared types, must setoutDirandcomposite: trueif anything downstream references it —noEmitcannot be combined withcomposite.
Frequently Asked Questions
Should .tsbuildinfo files be committed to git?
No. .tsbuildinfo files are machine- and path-specific incremental caches, not portable build artifacts. Committing them causes false cache hits or misses when a different machine or CI runner reads a snapshot that does not match its own file timestamps and absolute paths. Add *.tsbuildinfo to .gitignore and instead cache the files through your CI provider’s dedicated cache action, keyed on a lockfile hash.
Does tsc --build work with tsup or esbuild instead of tsc for the actual emit?
Not directly. Composite builds and the references graph are a tsc --build concept; tsup and esbuild do not read or produce .tsbuildinfo files. A common pattern is to run tsc --build solely for type checking and .d.ts emission across the graph, then run tsup separately per package for the actual JavaScript bundle output.
How much faster is an incremental tsc --build compared to a full rebuild?
It depends on how much of the graph changed, but for a change isolated to a single leaf package in a ten-package repository, an incremental build typically only recompiles that package and its direct dependents, skipping everything upstream and any sibling package with no dependency relationship. A full rebuild recompiles every package regardless of what changed.
Can I run tsc --build in watch mode across a whole monorepo?
Yes, with tsc --build --watch. It watches every referenced project’s source files and recompiles only the affected subgraph when a file changes, in the correct dependency order, which is significantly more efficient than running a separate tsc --watch process per package.
Related
- tsconfig Project References for Monorepos — how to wire the
referencesgraph that composite builds walk. - Publishing Packages from a pnpm Workspace — why a release build should force a clean recompile rather than trust the incremental cache.
- Modern Build Tools: tsup, Rollup, and esbuild — pairing
tsc --buildfor type checking with a faster bundler for the actual JavaScript emit.