TypeScript Project References for Incremental Monorepo Builds
Use TypeScript project references with composite tsconfig files to get incremental, type-safe monorepo builds. Each package emits declarations, the root tsconfig orders builds, and tsc --build handles dependency order.
23 Nov 2025, 08:24 UTC

The problem with a single tsconfig in a growing monorepo
Multiple packages share types and utilities, but a single TypeScript compilation re-checks everything on every change. The useful takeaway is to use project references with composite builds so each package compiles incrementally and type information crosses package boundaries only through emitted declaration files.
Requirements
A monorepo with separate TypeScript projects that need cross-project type checking and incremental builds. Each project must be able to build independently and be consumed by others without importing source .ts files directly.
To satisfy that, every referenced project needs a tsconfig with "composite": true, "declaration": true and a stable "outDir". A root tsconfig lists the references so tsc can order builds.
Smallest suitable design
Keep the root tsconfig minimal. It only enables project references and points at sub-configs.
{ "files": [], "references": [ { "path": "./packages/utils" }, { "path": "./packages/app" } ] }Each sub-project owns its compiler options. No build script is required beyond tsc.
{ "compilerOptions": { "target": "ES2020", "module": "ESNext", "declaration": true, "composite": true, "outDir": "./dist", "rootDir": "./src", "strict": true }, "include": ["src"] }{ "compilerOptions": { "target": "ES2020", "module": "ESNext", "outDir": "./dist", "rootDir": "./src", "strict": true }, "references": [ { "path": "../utils" } ], "include": ["src"] }Run builds from the repo root with write permission to each package's outDir. The command is:
tsc --buildFor incremental development:
tsc --build --watchRisk: adding compilerOptions to the root tsconfig overrides sub-project settings and can cause confusing errors. Keep the root config minimal.
Trust and data boundaries
Type information flows only through .d.ts files emitted by referenced projects. Runtime code is never shared across the boundary; the consumer imports from the built output of the dependency.
This creates a compile-time contract. If packages/utils changes a public type, it must re-emit declarations and the dependent project recompiles against those declarations. Accidental runtime coupling is prevented because source files are not on the consumer's module resolution path.
Ensure all referenced projects have "composite": true. Without it tsc ignores the reference and may produce duplicate or missing declaration files.
Operational checks
Local development: run tsc --build --watch from the repo root. Changing a type in packages/utils should trigger a rebuild of packages/app automatically if the change is compatible.
CI type safety without emitting:
tsc --build --noEmitRun with read access to source and write access only if emitting. Check the result by verifying that each package's outDir contains .js and .d.ts files and that .tsbuildinfo exists for incremental caching.
Limitations: project references assume a TypeScript-only pipeline. They do not bundle assets or produce a single distributable.
Failure modes
Circular reference errors. If project A references B and B references A, tsc will reject the build. This is a design signal to extract shared types into a third package.
Stale .tsbuildinfo caches. Deleting outDir or .tsbuildinfo forces a clean rebuild. Mismatched target/lib settings across references can cause type-check failures that appear only in the consumer.
Missing "declaration": true. Without declarations the consumer has no type information and the build succeeds with any types, breaking the boundary.
Avoid mixing project references with --isolatedModules unless you also enable allowSyntheticDefaultImports and verify emitted code does not rely on cross-module type-only constructs.
Conditions that would change the design
If the monorepo must publish a single bundled package, project references alone are insufficient; add a bundler step after type checking.
If the team prefers a JavaScript-only build pipeline such as esbuild or swc for speed, project references can be kept for type checking only with tsc --noEmit, while emit is handled elsewhere.
If runtime type validation is required and the team adopts a validation library that bypasses compile-time contracts, the declaration boundary becomes less authoritative and additional runtime checks are needed.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.