TypeScript Project References for Faster Monorepo Builds

This article explores how TypeScript project references optimize build performance in monorepo architectures. You will learn the mechanics behind project references, how they enforce package boundaries, and how combining them with TypeScript's build mode enables true incremental compilation and faster feedback loops for web developers.

The Monorepo Compilation Bottleneck

In standard monorepo setups, a single root tsconfig.json often manages every package, or packages compile independently using complex orchestration scripts. Without project references, the TypeScript compiler (tsc) treats the entire workspace as a massive, monolithic graph. Any minor edit requires the compiler to re-parse and re-typecheck extensive dependency chains. Even with file watching, memory consumption balloons, and type-checking can stall development workflows for minutes in large codebases.

How Project References Work

Introduced in TypeScript 3.0, project references allow you to structure a codebase into smaller, independent projects that reference one another. Each package maintains its own tsconfig.json configured with two key settings:

  • composite: true: Enforces strict modularity rules, such as ensuring all implementation files are covered by include or files, and mandates the generation of declaration files (.d.ts).
  • references: An array that explicitly declares dependencies on other projects by file path.
{
  "compilerOptions": {
    "composite": true,
    "declaration": true,
    "outDir": "./dist"
  },
  "references": [
    { "path": "../core" },
    { "path": "../utils" }
  ]
}

Decoupling Implementation via Type Declarations

The primary performance gain stems from how TypeScript resolves types across package boundaries. Instead of parsing the raw .ts source files of upstream dependencies, the compiler reads only their generated .d.ts type definitions.

This abstraction creates a stable API boundary between packages. If an internal implementation detail inside a core package changes without altering its public type signatures, downstream packages do not need to be re-typechecked. The compiler recognizes that the public contract remains identical, preventing unnecessary cascade recompilations.

Smart Caching with tsconfig.tsbuildinfo

When project references are enabled, TypeScript creates a tsconfig.tsbuildinfo file in each project's output directory. This file functions as a local state manifest, tracking:

  • Exact file hashes for source files and output files.
  • Timestamps and dependency relationships.
  • Compiler options used during the previous build.

When you execute tsc --build (or tsc -b), the compiler evaluates these manifests against the project dependency graph. If neither a package's source files nor its upstream declaration files have changed, TypeScript skips compilation for that package entirely.

Parallel Execution and Graph Traversal

The tsc --build command understands the topological order of your workspace. It computes a directed acyclic graph (DAG) of your projects, enabling:

  1. Topological Builds: Compiling dependencies strictly before consumers to ensure definitions exist.
  2. Targeted Rebuilds: Compiling only the dirty packages and their direct dependents when declarations shift.
  3. Monorepo Task Runner Integration: Build orchestrators like Turborepo, Nx, and Wireit rely on the structured boundaries created by project references to safely cache and parallelize compilation across multiple CPU cores.

By isolating packages into self-contained compilation units and leveraging declaration-only dependencies alongside .tsbuildinfo caching, TypeScript project references transform full-workspace recompilations into targeted, sub-second incremental builds.