Skip to content

Project References and Incremental Builds

advanced13 min read

When Single tsconfig Isn't Enough

Here's a problem you'll definitely hit as your codebase grows: a single tsconfig.json compiling everything becomes a bottleneck. A 200-file project compiles in seconds. A 2000-file monorepo? Minutes. Project references split the codebase into independently compilable units, enabling incremental builds that only recompile what changed.

Mental Model

Think of project references as building with LEGO sets instead of one giant pile. Without references, TypeScript dumps all your files into one pile and checks everything from scratch. With references, each package is a separate LEGO set (composite project) with its own box (build output). When you change one set, only that set gets rebuilt — the others use their already-assembled state from the box.

Enabling Incremental Builds

Good news: the simplest optimization doesn't even require project references:

{
  "compilerOptions": {
    "incremental": true,
    "tsBuildInfoFile": ".tsbuildinfo"
  }
}

This creates a .tsbuildinfo file that caches the previous compilation. On the next compile, TypeScript diffs against the cache and only rechecks changed files and their dependents. For single-project setups, this alone can cut build times significantly.

Composite Projects

A composite project is a TypeScript project that declares itself as a buildable unit:

// packages/shared/tsconfig.json
{
  "compilerOptions": {
    "composite": true,
    "declaration": true,
    "declarationMap": true,
    "outDir": "dist",
    "rootDir": "src"
  },
  "include": ["src"]
}

composite: true enables three things:

  1. Forces declaration: true (generates .d.ts files)
  2. Forces rootDir to be set
  3. Enables the project to be referenced by other projects
Why composite requires declaration

Other projects that reference a composite project read its .d.ts files instead of recompiling its source. This is the core optimization — downstream projects don't need to parse and type-check the source of their dependencies, just the pre-built declarations.

Project References

The root tsconfig.json references composite sub-projects:

// tsconfig.json (root)
{
  "references": [
    { "path": "packages/shared" },
    { "path": "packages/server" },
    { "path": "packages/client" }
  ],
  "files": [] // Root doesn't compile anything itself
}

Each sub-project can reference other sub-projects:

// packages/server/tsconfig.json
{
  "compilerOptions": {
    "composite": true,
    "declaration": true,
    "outDir": "dist",
    "rootDir": "src"
  },
  "references": [
    { "path": "../shared" }
  ],
  "include": ["src"]
}

Build Mode

This is important — you need to use tsc --build (or tsc -b) to compile project references, not plain tsc:

# Build all projects in dependency order
tsc --build

# Build a specific project and its dependencies
tsc --build packages/server

# Force rebuild everything
tsc --build --force

# Clean build outputs
tsc --build --clean

# Watch mode with project references
tsc --build --watch

# Verbose output (shows what's being rebuilt)
tsc --build --verbose
How tsc --build determines what to rebuild

When you run tsc --build, TypeScript:

  1. Reads the references array and builds a dependency graph of projects
  2. Topologically sorts the graph (shared → server → client)
  3. For each project, compares the .tsbuildinfo file against current source files
  4. If any source file is newer than the build output, recompiles that project
  5. If a dependency was rebuilt, recompiles all downstream projects

The key optimization: if packages/shared hasn't changed, TypeScript skips it entirely. If only packages/client changed, only the client is recompiled — but it reads the pre-built .d.ts files from packages/shared instead of recompiling shared source.

Monorepo Setup

A typical monorepo with project references:

monorepo/
├── tsconfig.json              # Root — references all packages
├── packages/
│   ├── shared/
│   │   ├── tsconfig.json      # composite: true
│   │   ├── src/
│   │   │   ├── types.ts
│   │   │   └── utils.ts
│   │   └── dist/              # Build output + .d.ts files
│   ├── api/
│   │   ├── tsconfig.json      # references: [shared]
│   │   └── src/
│   └── web/
│       ├── tsconfig.json      # references: [shared]
│       └── src/
// packages/shared/tsconfig.json
{
  "compilerOptions": {
    "composite": true,
    "declaration": true,
    "declarationMap": true,
    "sourceMap": true,
    "outDir": "dist",
    "rootDir": "src",
    "strict": true,
    "target": "ES2022",
    "module": "ESNext",
    "moduleResolution": "bundler"
  },
  "include": ["src"]
}
// packages/web/tsconfig.json
{
  "compilerOptions": {
    "composite": true,
    "declaration": true,
    "outDir": "dist",
    "rootDir": "src",
    "strict": true,
    "jsx": "react-jsx",
    "target": "ES2022",
    "module": "ESNext",
    "moduleResolution": "bundler"
  },
  "references": [
    { "path": "../shared" }
  ],
  "include": ["src"]
}
Common Trap

With project references, you MUST import from the package name (as configured in package.json), not relative paths across project boundaries:

// WRONG — relative import crossing project boundary
import { User } from "../../shared/src/types";

// CORRECT — import from the package
import { User } from "@myorg/shared";

This requires proper exports configuration in each package's package.json and correct path resolution. Most monorepo tools (Turborepo, Nx, pnpm workspaces) handle this automatically.

Production Scenario: Full Monorepo Configuration

// Root tsconfig.json
{
  "compilerOptions": {
    "strict": true,
    "target": "ES2022",
    "module": "ESNext",
    "moduleResolution": "bundler",
    "esModuleInterop": true,
    "skipLibCheck": true,
    "forceConsistentCasingInFileNames": true,
    "resolveJsonModule": true,
    "isolatedModules": true,
    "verbatimModuleSyntax": true
  }
}
// packages/shared/tsconfig.json
{
  "extends": "../../tsconfig.json",
  "compilerOptions": {
    "composite": true,
    "declaration": true,
    "declarationMap": true,
    "outDir": "dist",
    "rootDir": "src"
  },
  "include": ["src"]
}
// packages/shared/package.json
{
  "name": "@myorg/shared",
  "exports": {
    ".": {
      "types": "./dist/index.d.ts",
      "import": "./dist/index.js"
    }
  },
  "files": ["dist"]
}
Execution Trace
Graph
tsc --build reads references
Builds dependency graph: shared → api, shared → web
Check
Compare .tsbuildinfo against source
shared unchanged? Skip. api source changed? Mark for rebuild.
Build
Compile changed projects in topological order
shared (skip) → api (rebuild) → web (skip if api not a dependency)
Output
.js + .d.ts + .d.ts.map + .tsbuildinfo
Declaration files are what downstream projects consume
Result
Only changed projects recompiled
2 seconds instead of 60 for full rebuild
What developers doWhat they should do
Using relative imports across project reference boundaries
Project references compile independently. Cross-boundary relative imports bypass the declaration boundary.
Import from package names: import { X } from '@myorg/shared', not '../../shared/src/x'
Forgetting composite: true on referenced projects
composite enables declaration generation and build tracking — without it, tsc --build can't manage the project
Every project listed in another project's references must have composite: true
Not running tsc --build and using tsc directly with project references
Plain tsc doesn't understand project references. tsc --build handles dependency ordering and incremental compilation.
Always use tsc --build (or tsc -b) when working with project references
Committing .tsbuildinfo and dist/ for composite projects to git
These files are generated by tsc --build and change frequently. Committing them causes merge conflicts.
Add .tsbuildinfo and dist/ to .gitignore — they're build artifacts
Quiz
What is the main performance benefit of project references?
Quiz
Why does composite: true require declaration: true?
Quiz
What does tsc --build --watch do differently from tsc --watch?

Challenge: Configure a 3-Package Monorepo

// Set up TypeScript project references for this monorepo structure:
//
// packages/
//   types/       — shared TypeScript types (no runtime code)
//   core/        — business logic (depends on types)
//   web/         — React app (depends on types AND core)
//
// Requirements:
// 1. All packages use strict mode
// 2. types/ generates .d.ts files only (declarationOnly)
// 3. core/ depends on types/
// 4. web/ depends on both types/ and core/
// 5. Root tsconfig.json references all three
//
// Write all four tsconfig.json files:
// tsconfig.json (root)
{
  "compilerOptions": {
    "strict": true,
    "target": "ES2022",
    "module": "ESNext",
    "moduleResolution": "bundler",
    "esModuleInterop": true,
    "skipLibCheck": true,
    "forceConsistentCasingInFileNames": true
  },
  "references": [
    { "path": "packages/types" },
    { "path": "packages/core" },
    { "path": "packages/web" }
  ],
  "files": []
}
// packages/types/tsconfig.json
{
  "extends": "../../tsconfig.json",
  "compilerOptions": {
    "composite": true,
    "declaration": true,
    "emitDeclarationOnly": true,
    "outDir": "dist",
    "rootDir": "src"
  },
  "include": ["src"]
}
// packages/core/tsconfig.json
{
  "extends": "../../tsconfig.json",
  "compilerOptions": {
    "composite": true,
    "declaration": true,
    "outDir": "dist",
    "rootDir": "src"
  },
  "references": [
    { "path": "../types" }
  ],
  "include": ["src"]
}
// packages/web/tsconfig.json
{
  "extends": "../../tsconfig.json",
  "compilerOptions": {
    "composite": true,
    "declaration": true,
    "outDir": "dist",
    "rootDir": "src",
    "jsx": "react-jsx"
  },
  "references": [
    { "path": "../types" },
    { "path": "../core" }
  ],
  "include": ["src"]
}

The key: each package uses extends for shared options, composite: true for independent compilation, and references for type-safe cross-package imports. Run tsc --build from the root to compile all packages in the correct dependency order.

Key Rules
  1. 1composite: true + declaration: true makes a project referenceable — downstream reads .d.ts instead of source
  2. 2Always use tsc --build (not plain tsc) with project references — it handles dependency ordering and incremental compilation
  3. 3Import across project boundaries via package names, not relative paths
  4. 4incremental: true alone (without references) still provides significant speedup via .tsbuildinfo caching
  5. 5Add .tsbuildinfo and composite project dist/ directories to .gitignore — they're build artifacts