TypeScript 6.0 Project References at Scale: Incremental Builds, Composite Projects, and What Teams Get Wrong
Most monorepo build failures stem from misunderstood project references. Learn how composite projects, incremental builds, and tsc -b actually work—and the subtle configuration mistakes that destroy build performance at scale.
Why Project References Matter at Scale
Most monorepo build failures stem from a misunderstanding of how TypeScript project references actually work. Teams adopt them for incremental builds, then watch CI times balloon to 15+ minutes because they've configured composite projects incorrectly. The symptoms are always the same: full rebuilds on every commit, declaration emit failures that crash the build, and path mappings that silently break after a refactor.
The root cause is that project references introduce a constraint the TypeScript compiler cannot work around: every referenced project must be a composite project, and every composite project must emit declaration files. When developers set "declaration": false in a referenced project—a pattern that works fine in single-project setups—the compiler throws TS6304 and the build stops. When they create circular references between packages, the build graph becomes unsolvable and tsc -b runs forever.
flowchart LR
A("Developer configures project references")
B("Sets declaration: false in a composite project")
C("tsc -b crashes with TS6304")
D("Build pipeline fails")
A --> B
B --> C
C --> D
style C stroke:#ef4444,fill:#450a0a,color:#fca5a5
style D stroke:#ef4444,fill:#450a0a,color:#fca5a5
The solution is to treat project references as a build orchestration tool, not just a type-checking optimization. Each package in the monorepo becomes a composite project with explicit dependencies declared in tsconfig.json. The compiler then builds the reference graph topologically, emitting .d.ts files for downstream consumers and caching incremental results in .tsbuildinfo files. When configured correctly, this approach cuts build times by 70% in a 50-package monorepo because the compiler skips unchanged projects entirely.
flowchart LR
A("Developer configures project references")
B("All composites emit declarations")
C("tsc -b builds only changed projects")
D("Build completes in seconds")
A --> B
B --> C
C --> D
style C stroke:#34d399,fill:#0b3b2e,color:#d1fae5
style D stroke:#34d399,fill:#0b3b2e,color:#d1fae5
This post covers the essential patterns for configuring project references at scale, the build-mode mechanics that make incremental compilation work, and the configuration mistakes that destroy performance. The focus is on real-world monorepo setups where dozens of packages depend on each other and CI build time directly impacts deployment velocity.
Key Takeaways
- Project references require every referenced project to be a composite project with
"declaration": true—setting"declaration": falsecrashes the build with TS6304. - The
tsc -bbuild mode uses.tsbuildinfofiles to track dependencies and skip unchanged projects, reducing build times by 70% in large monorepos when configured correctly. - Circular references between packages make the build graph unsolvable—the compiler cannot determine build order and either fails or runs indefinitely.
- Path mappings in
tsconfig.jsonmust align exactly with project references or the compiler silently uses stale declaration files, causing runtime type mismatches. - TypeScript 6.0 enforces stricter validation of composite project configurations, breaking builds that previously succeeded with invalid reference graphs.
Understanding Composite Projects and the Reference Graph
A composite project is a TypeScript project configured to participate in a multi-project build. The "composite": true flag in tsconfig.json enables three critical behaviors: declaration file emission becomes mandatory, the compiler generates a .tsbuildinfo file to track build state, and the project can be referenced by other projects using the "references" array.
The build graph is a directed acyclic graph (DAG) where each node is a composite project and each edge represents a reference dependency. When developers run tsc -b, the compiler traverses this graph topologically, building dependencies before dependents. If package @repo/ui references @repo/shared, the compiler ensures @repo/shared builds first and emits its .d.ts files before @repo/ui starts type-checking.
flowchart TD
Root["Root tsconfig.json<br/>(references only)"]
Shared["@repo/shared<br/>(composite: true)"]
Utils["@repo/utils<br/>(composite: true)"]
UI["@repo/ui<br/>(references: shared, utils)"]
API["@repo/api<br/>(references: shared)"]
Root --> Shared
Root --> Utils
Root --> UI
Root --> API
UI --> Shared
UI --> Utils
API --> Shared
style Root stroke:#7c9cf0,fill:#142544,color:#eaf2ff
style Shared stroke:#c084fc,fill:#3b0764,color:#f3e8ff,stroke-width:4px
The implication here is that every composite project must emit declaration files because downstream projects consume those files for type-checking, not the original source code. This is the constraint that breaks most setups: developers accustomed to setting "declaration": false for faster builds encounter TS6304 the moment they add a reference.
The .tsbuildinfo file stores the build state for incremental compilation. It contains file hashes, module resolution results, and dependency metadata. When the compiler runs, it compares current file hashes to those in .tsbuildinfo and skips projects where nothing changed. This is what makes tsc -b fast at scale—it rebuilds only the subgraph affected by recent edits.
The failure mode here is subtle but expensive: if developers commit .tsbuildinfo files to version control, the incremental cache becomes invalid whenever teammates merge conflicting changes. The compiler sees mismatched hashes and forces a full rebuild. The correct pattern is to add *.tsbuildinfo to .gitignore and regenerate the cache on every CI run.
Setting Up Project References: A Step-by-Step Configuration
The root tsconfig.json in a monorepo should contain no "files" or "include" patterns—only a "references" array pointing to every package. This configuration exists solely to provide an entry point for tsc -b and should never compile code directly.
// tsconfig.json (root)
{
"files": [],
"references": [
{ "path": "./packages/shared" },
{ "path": "./packages/utils" },
{ "path": "./packages/ui" },
{ "path": "./packages/api" }
]
}Each package needs its own tsconfig.json with "composite": true and explicit "references" to its dependencies. The "outDir" must be set because composite projects require predictable output paths for .d.ts files. The "declarationMap" option is optional but recommended for source map navigation in editors.
// packages/shared/tsconfig.json
{
"compilerOptions": {
"composite": true,
"declaration": true,
"declarationMap": true,
"outDir": "./dist",
"rootDir": "./src"
},
"include": ["src/**/*"]
}A downstream package references its dependencies using the "references" array. The compiler uses these references to locate .d.ts files in the dependency's outDir, not its source directory. This distinction is critical: if developers forget to build the dependency first, the downstream project fails with module resolution errors.
// packages/ui/tsconfig.json
{
"compilerOptions": {
"composite": true,
"declaration": true,
"outDir": "./dist",
"rootDir": "./src"
},
"include": ["src/**/*"],
"references": [
{ "path": "../shared" },
{ "path": "../utils" }
]
}The build command changes from tsc to tsc -b (build mode). Running tsc -b at the root builds the entire reference graph in dependency order. Running tsc -b packages/ui builds only packages/ui and its transitive dependencies. The --force flag forces a full rebuild, ignoring .tsbuildinfo state—useful after major refactors but slow in CI.
Path mappings in the root tsconfig.json should mirror the package structure but are not required for project references to work. The compiler resolves references via the "references" array and the referenced project's outDir, not via paths. However, most monorepo tooling expects path mappings for editor support and Jest module resolution.
Incremental Builds and Build Mode: How tsc -b Actually Works
The tsc -b command operates in build mode, where the compiler becomes a build orchestrator managing multiple projects instead of a single-project type-checker. Build mode reads the reference graph, computes a topological sort, and processes projects in dependency order. For each project, it checks the .tsbuildinfo file to determine if a rebuild is necessary.
flowchart LR
A("tsc -b invoked")
B("Load reference graph from root")
C("Compute topological sort")
D("Check .tsbuildinfo for each project")
E("Rebuild changed projects only")
F("Emit .d.ts and .tsbuildinfo")
A --> B
B --> C
C --> D
D --> E
E --> F
style E stroke:#c084fc,fill:#3b0764,color:#f3e8ff,stroke-width:4px
style F stroke:#34d399,fill:#0b3b2e,color:#d1fae5
The .tsbuildinfo file contains a content hash for every input file in the project and a timestamp for the last build. When the compiler runs, it rehashes the input files and compares them to the stored hashes. If all hashes match and no upstream dependencies changed, the compiler skips the project entirely. If any hash differs, the compiler rebuilds and updates .tsbuildinfo.
The dependency tracking extends across projects. If @repo/shared rebuilds because a source file changed, every downstream project that references @repo/shared also rebuilds—even if their own source files are unchanged. This is correct behavior because the .d.ts output from @repo/shared might have changed, invalidating type-checking in dependents.
The performance characteristic that matters is cache invalidation scope. In a monorepo with 50 packages where 5 packages reference @repo/shared, editing one file in @repo/shared triggers 6 rebuilds (the package itself plus 5 dependents). Editing a file in @repo/api that no other package references triggers 1 rebuild. This is why monorepo architecture—minimizing shared dependencies—directly impacts build performance.
The --clean flag removes all build outputs and .tsbuildinfo files, forcing the next build to start from scratch. The --dry flag prints what would be built without actually building it, useful for debugging reference graph issues. The --verbose flag shows detailed dependency resolution and rebuild decisions—essential for understanding why a project rebuilt when developers expected it to be cached.
Common Mistakes Teams Make: declaration: false, Circular References, and Path Mapping
The most common mistake is setting "declaration": false in a composite project. Developers do this to speed up local builds, not realizing that composite projects must emit declarations for downstream consumers. The compiler enforces this with TS6304, and the fix is simple: remove "declaration": false or set "composite": false—but the latter removes the project from the reference graph entirely.
flowchart LR
A("Developer disables declaration emit")
B("Composite project has declaration: false")
C("Downstream project references it")
D("tsc -b throws TS6304")
E("Build crashes")
A --> B
B --> C
C --> D
D --> E
style D stroke:#fbbf24,fill:#3a2f0b,color:#fef3c7
style E stroke:#ef4444,fill:#450a0a,color:#fca5a5
F("Developer keeps declaration: true")
G("Composite project emits .d.ts")
H("Downstream project consumes declarations")
I("tsc -b succeeds")
J("Incremental builds work")
A --> F
F --> G
G --> H
H --> I
I --> J
style I stroke:#34d399,fill:#0b3b2e,color:#d1fae5
style J stroke:#34d399,fill:#0b3b2e,color:#d1fae5
Circular references between packages make the build graph unsolvable. If @repo/ui references @repo/api and @repo/api references @repo/ui, the compiler cannot determine build order. The error message is usually cryptic ("Project references may not form a closed loop"), and the fix requires architectural change: extract shared types to a third package or redesign the dependency relationship.
The implication here is that project references enforce clean architecture. Circular dependencies that silently degrade performance in single-project setups become build-breaking errors in multi-project setups. This is a feature, not a bug—it surfaces design problems early.
Path mappings that don't align with project references cause silent type mismatches. If tsconfig.json contains "paths": { "@repo/shared": ["packages/shared/src"] } but the project reference points to packages/shared (which outputs to dist), the compiler resolves types from src during development and from dist during tsc -b. If a developer edits a source file without rebuilding, the path mapping serves stale types.
The correct pattern is to point path mappings at dist, not src, or omit them entirely and rely on package.json exports. Most bundlers (Webpack, Vite, esbuild) resolve project references correctly without path mappings, and omitting them eliminates the stale-cache footgun.
The final mistake is committing .tsbuildinfo files to version control. These files contain absolute paths and build-machine-specific state. When teammates check out the repo, the compiler sees mismatched paths and invalidates the cache, forcing full rebuilds. The fix is adding *.tsbuildinfo to .gitignore and regenerating it on every clone.
TypeScript 6.0 Changes: What's Different for Project References
TypeScript 6.0 enforces stricter validation of composite project configurations. Projects that compiled successfully in 5.x with invalid reference graphs now fail with TS6304 or TS6305. The specific change is that the compiler no longer silently ignores "declaration": false in a composite project—it errors immediately.
flowchart TD
TS5["TypeScript 5.x behavior"]
TS6["TypeScript 6.0 behavior"]
Invalid["declaration: false in composite"]
Silent["Silently ignores, builds anyway"]
Error["TS6304: must enable declaration"]
Breaks["Build crashes"]
TS5 --> Invalid
Invalid --> Silent
TS6 --> Invalid
Invalid --> Error
Error --> Breaks
style Silent stroke:#fbbf24,fill:#3a2f0b,color:#fef3c7
style Error stroke:#ef4444,fill:#450a0a,color:#fca5a5
style Breaks stroke:#ef4444,fill:#450a0a,color:#fca5a5
The second change is improved .tsbuildinfo content hashing. TypeScript 6.0 uses a faster hash algorithm (xxHash64 instead of MD5) and stores more granular dependency metadata. This means builds after upgrading to 6.0 invalidate all existing .tsbuildinfo files—the first build post-upgrade is always a full rebuild. Subsequent builds are faster because the new hash algorithm runs 40% faster on large codebases.
The third change affects watch mode (tsc -b --watch). In 5.x, watch mode sometimes missed changes in referenced projects and served stale types. TypeScript 6.0 adds file-system event listeners for every project in the reference graph, ensuring changes propagate correctly. This makes watch mode viable for monorepo development where it was previously unreliable.
The practical implication is that upgrading to TypeScript 6.0 requires a one-time audit of all tsconfig.json files to ensure "composite": true implies "declaration": true in every referenced project. Teams running large monorepos should budget 2-4 hours for this audit and testing. The payoff is faster builds and more reliable incremental compilation—worth the migration cost.
Production Patterns: Monorepo Build Pipelines and CI Optimization
The optimal CI build pattern for project references is a two-stage pipeline: dependency installation followed by tsc -b at the root. The key insight is that tsc -b handles build ordering automatically, so there's no need for manual lerna run or turbo orchestration unless non-TypeScript build steps are involved.
flowchart LR
Install("npm ci or pnpm install")
Build("tsc -b at root")
Cache("Cache node_modules and .tsbuildinfo")
Test("Run tests in parallel")
Deploy("Deploy changed packages")
Install --> Build
Build --> Cache
Cache --> Test
Test --> Deploy
style Build stroke:#c084fc,fill:#3b0764,color:#f3e8ff,stroke-width:4px
style Cache stroke:#34d399,fill:#0b3b2e,color:#d1fae5
The .tsbuildinfo cache should be stored in CI but keyed by a hash of all tsconfig.json files and package.json dependencies. If either changes, the cache invalidates. Most CI systems (GitHub Actions, CircleCI, GitLab CI) support this pattern natively using cache keys like tsc-{{ checksum "pnpm-lock.yaml" }}-{{ hashFiles "**/tsconfig.json" }}.
The failure mode here is caching .tsbuildinfo without invalidating it when dependencies change. If a developer updates a dependency and CI restores an old .tsbuildinfo, the compiler thinks nothing changed and skips the build. The deployed code then crashes because it's using stale types. The fix is including dependency hashes in the cache key.
For repositories with 30+ packages, consider splitting tsc -b into parallel jobs based on package subgraphs. If @repo/ui and @repo/api have no shared dependencies beyond @repo/shared, they can build in parallel after @repo/shared finishes. This requires manually partitioning the reference graph, but it cuts build times by 50% in large monorepos where the DAG is wide rather than deep.
The watch mode pattern for local development is tsc -b --watch in a terminal alongside the dev server. The compiler rebuilds changed projects incrementally, and most bundlers (Vite, Next.js) detect the updated .d.ts files and hot-reload. This setup is faster than running a full monorepo build on every save because only affected projects rebuild.
The pattern for library publishing is to run tsc -b once in CI, then publish the dist directory from each package. The key is ensuring package.json "files" includes dist but excludes src, and "types" points to dist/index.d.ts. This guarantees consumers get compiled outputs, not source files, which is critical for cross-monorepo compatibility.
Frequently Asked Questions
What happens if I forget to set composite: true in a referenced project?
The TypeScript compiler throws TS6305 ("Cannot reference project without composite flag") and the build fails immediately. The fix is adding "composite": true to the project's tsconfig.json and ensuring "declaration": true is also set.
Can I use project references with JavaScript projects?
Yes, but only if the JavaScript project emits .d.ts files via "declaration": true and "allowJs": true. Most teams using project references at scale use TypeScript exclusively because the build graph requires typed outputs at every node.
How do I debug which project is causing a rebuild?
Run tsc -b --verbose to see detailed build decisions. The output shows which projects have changed files, which .tsbuildinfo files are stale, and which projects are being skipped. This is essential for diagnosing unexpected rebuilds in large monorepos.
Should I commit .tsbuildinfo files to Git?
No. These files contain machine-specific absolute paths and build state that becomes stale when teammates check out the repo. Add *.tsbuildinfo to .gitignore and regenerate them on every CI run and after every git pull.
What's the difference between project references and path mappings?
Project references define build-time dependencies and enable incremental compilation via tsc -b. Path mappings are editor/bundler aliases for module resolution. Both can coexist, but project references are required for multi-project builds while path mappings are optional syntactic sugar.
Conclusion: When to Use Project References vs Alternative Strategies
Project references solve one problem exceptionally well: incremental compilation in monorepos with explicit package dependencies. When configured correctly, they cut build times by 70% because the compiler skips unchanged projects entirely. The tradeoff is configuration complexity—every package needs a tsconfig.json, every dependency must be declared twice (in package.json and references), and circular dependencies become build errors.
The alternative strategy is single-project compilation with path mappings and a bundler (Vite, esbuild, Turbopack) that handles module resolution. This works for small monorepos (under 10 packages) where build time is already fast and the overhead of managing composite projects exceeds the benefit. It also works for applications that consume libraries from npm—there's no need for project references when dependencies are pre-compiled.
The deciding factor is build time. If tsc takes under 30 seconds for the entire monorepo, project references add complexity without meaningful benefit. If it takes over 2 minutes, project references pay for themselves immediately. If it takes over 10 minutes, project references are mandatory—no other TypeScript-native solution delivers comparable incremental build performance.
That covers the essential patterns for TypeScript project references at scale. Apply these in production and the difference will be immediate. For deeper exploration of declaration file optimization, see TypeScript Isolated Declarations for Monorepo Performance and Parallel .d.ts Generation Strategies.