for TypeScript monorepos that stop recompiling everything on every build
composite, references, and tsc --build replace a full recompile with incremental, parallelizable per-package builds.
Table of Contents
- 1. The problem: slow tsc builds in monorepos
- 2. composite and references in tsconfig.json
- 3. Build order and the .tsbuildinfo cache
- 4. tsc --build: incremental and parallelizable
- 5. declarationMap and cross-package go-to-definition
- 6. Path mapping vs. Project References: the difference
- 7. Common pitfalls: circular references, outDir conflicts
- 8. Integration with Turborepo and Nx
- 9. Migration strategy for existing monorepos
- 10. Summary
- 11. FAQ
1. The problem: slow tsc builds in monorepos
Without Project References, a single tsc run across a monorepo treats every package as one giant compilation unit. Any change, even in a single leaf package, forces the compiler to re-check the entire type graph.
In medium-sized monorepos with a dozen packages, that often means compile times in the tens of seconds for a one-line change, which makes IDE watch mode noticeably sluggish and breaks the development feedback loop.
Project References solve this structurally: each package becomes its own compilation unit with clearly defined dependencies, so the compiler only has to rebuild the packages actually affected, not the whole repository.
Without this structure, compile times in practice grow disproportionately with the number of packages, because every new file further inflates the entire type graph. As a team and repository grow, this effect increasingly becomes the biggest friction point in day-to-day development.
2. composite and references in tsconfig.json
A package becomes referenceable by setting composite: true in its tsconfig. That enforces extra rules: all input files must be explicitly covered by the include pattern, and declaration is implicitly turned on, because dependent packages consume the generated .d.ts files instead of the source.
In the consuming package, the references array points to the paths of its dependencies. TypeScript reads this into a directed acyclic graph, so it knows exactly in which order packages must be built.
It matters that references only controls build order, not module resolution itself. Actual imports between packages still need workspace linking via npm, pnpm, or Yarn; Project References complement that, they don't replace it.
// packages/core/tsconfig.json
{
"compilerOptions": {
"composite": true,
"declaration": true,
"declarationMap": true,
"outDir": "dist",
"rootDir": "src"
},
"include": ["src"]
}
// packages/api/tsconfig.json
{
"compilerOptions": { "composite": true, "outDir": "dist", "rootDir": "src" },
"references": [{ "path": "../core" }],
"include": ["src"]
}
3. Build order and the .tsbuildinfo cache
On every build of a referenced package, TypeScript writes a .tsbuildinfo file containing file hashes, diagnostics, and dependency information. On the next build, the compiler compares this state and only compiles what has actually changed since the last run.
This cache is the real reason for the speedup: a build with no changes takes only milliseconds thanks to .tsbuildinfo, because the compiler quickly recognizes that no package in the graph needs recompiling.
In CI environments it pays to cache .tsbuildinfo files between runs, for instance via GitHub Actions or GitLab CI build caching, because a cold cache erases the speed advantage for the first build.
4. tsc --build: incremental and parallelizable
The tsc --build command (short: tsc -b) reads the reference graph starting from the given tsconfig and builds all dependencies in the correct order before building the target package itself.
Combined with the --watch flag, it becomes a multi-package watch mode that, after a change in a core package, automatically rebuilds only the packages depending on it, instead of restarting the entire watch process.
Additional flags like --verbose show which packages were skipped and why, which is invaluable when debugging unexpectedly slow builds or misconfigured references.
# Builds all referenced packages in the correct order
tsc --build packages/api
# Watch mode across the entire reference graph
tsc --build --watch
# Forces a full rebuild, ignoring .tsbuildinfo
tsc --build --force
5. declarationMap and cross-package go-to-definition
Without declarationMap: true, Go to Definition on a symbol from a referenced package only lands in the generated .d.ts file, not the actual source. For daily development work that is impractical, because comments and the real implementation are missing.
With declarationMap enabled, TypeScript generates additional source map files for the declarations, so the editor's go-to-definition jumps straight into the referenced package's .ts source file, comments and JSDoc blocks included.
This feature is a noticeable productivity gain in large monorepos, because developers navigating across package boundaries no longer notice they crossed a package boundary at all.
6. Path mapping vs. Project References: the difference
Many projects use paths in tsconfig for shorter imports like @core/utils. That is pure alias mapping for module resolution and has no effect on build order or incremental compilation.
Project References solve a different problem: they define what compilation units exist and in what order they must be built. Both mechanisms are not mutually exclusive, and in practice they are usually combined.
A common mistake is assuming paths alone guarantees cross-package type safety. Without composite and references, packages don't consistently type-check against each other, and changes in a core package don't reliably propagate to dependent packages.
7. Common pitfalls: circular references, outDir conflicts
TypeScript rejects circular references between two packages with a clear error, because the reference graph must be acyclic. In practice, such a cycle almost always signals an architecture problem requiring the extraction of a shared base package.
Another frequent mistake is a misconfigured outDir that accidentally overwrites another package's source files when rootDir is not cleanly set per package. The resulting error is often cryptic and only shows up as an unexpectedly empty or overwritten output file.
Forgetting declaration: true in a referenced package is also a classic: without generated .d.ts files, dependent packages can find the package on disk, but cannot import any meaningful types from it.
8. Integration with Turborepo and Nx
Build orchestrators like Turborepo or Nx build their own task graph logic on top of the package manager, which complements rather than competes with Project References: Project References ensure correct TypeScript builds per package, while Turborepo or Nx orchestrate, cache, and parallelize those tasks across the whole repository.
In practice, a Turborepo pipeline typically calls tsc --build per package and caches the output including .tsbuildinfo, so both TypeScript's own incremental cache and the orchestrator's remote cache apply.
It matters to declare the .tsbuildinfo files as build output in the orchestrator, otherwise a cache hit restores the compiled output but not the state tsc --build needs for its own incrementality.
9. Migration strategy for existing monorepos
A staged migration starts at the leaves of the dependency graph: packages with no internal dependencies get composite: true first, followed by packages that only reference already-migrated packages.
The table below compares the main approaches to monorepo builds and helps decide when the migration effort pays off.
| Approach | Incremental | Cross-package type safety | Setup effort |
|---|---|---|---|
| Single tsconfig for the whole repo | No | Yes, but slow | Minimal |
| Path mapping without References | No | Surface-level only | Low |
| Project References + tsc -b | Yes, via .tsbuildinfo | Yes, structurally enforced | Medium |
| Project References + Turborepo/Nx | Yes, plus remote cache | Yes | High, but scales |
Mironsoft
TypeScript migration, type safety, and team onboarding
A JavaScript codebase without type safety, but no time for a full migration?
We migrate existing JavaScript projects to TypeScript step by step, set up strict compiler settings cleanly, and bring teams to the same type-safety level with code reviews and style guides.
Migration Roadmap
Plan and execute a gradual JS-to-TS migration without big-bang risk.
Strict Mode Rollout
Set up tsconfig.json, ESLint rules, and CI checks for lasting type safety.
Team Onboarding
Bring developers up to speed on TypeScript best practices with workshops and reviews.
10. Summary
TS Project References
Core mechanism
composite + references split a repo into independent compilation units.
Speed
.tsbuildinfo turns unchanged packages into millisecond builds.
IDE comfort
declarationMap brings go-to-definition back to the actual source.
Complement
Turborepo/Nx orchestrate; Project References guarantee correctness.