TypeScript Project References for Fast Monorepo Builds
AI generated
type
TypeScript
Project References
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.

10 min read TypeScript 5.x Monorepo

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.

11. FAQ: TS Project References

1Do I have to switch all packages to composite at once?
No, migration can proceed step by step starting from the leaves of the dependency graph. A package without composite can still be referenced normally, as long as it doesn't itself need references.
2What happens if I accidentally delete .tsbuildinfo?
The next build becomes a full rebuild, because TypeScript finds no previous state to compare against. Functionally nothing changes, it just costs time once.
3Can I use Project References directly with a tool other than tsc, like esbuild?
esbuild ignores Project References entirely, since it performs no type checking. Project References are a feature of the TypeScript compiler itself, relevant for type checking and .d.ts generation, not for plain transpilation.
4Should .tsbuildinfo be checked into the Git repository?
Usually not; the file belongs in .gitignore and should instead be generated locally or cached in CI. A checked-in .tsbuildinfo can cause inconsistent state across differing environments.
5Does composite automatically enable strict mode?
No, composite and strict are independent options. composite only enforces explicit includes, enabled declaration, and a defined outDir, regardless of the strictness settings.
6How do I handle a circular dependency between two packages?
TypeScript rejects such a reference with an error. The usual fix is to extract the shared code both packages need into a third, common base package that both reference.
7Does tsc --build --watch perform well with twenty or more packages?
Yes, because watch mode decides per package, thanks to .tsbuildinfo, whether a rebuild is needed. Repository size mainly affects the very first cold build, not incremental changes.
8Does Project References replace a bundler like Vite or Webpack?
No, Project References only concern type checking and .d.ts generation by tsc. Bundling the runtime JavaScript files still requires a separate bundler.
9What's the difference between tsc --build and a plain tsc call in the referenced package?
A plain tsc call ignores the references array for build orchestration and only checks the current package. tsc --build reads the entire reference graph and builds dependencies in the correct order too.
10Is Project References worth it for a small monorepo with three packages?
The speed gain is usually small with so few packages, but the structural clarity around package boundaries and improved IDE navigation can still justify the modest setup effort.