TypeScript Project References: Multi-Project Builds

Managing a TypeScript monorepo without project references is painful. Every file change triggers a full rebuild of your entire codebase. Your IDE crawls as it tries to type-check thousands of files...

Key Insights

  • Project references enable TypeScript to build large codebases incrementally by treating each package as a separate compilation unit, reducing build times from minutes to seconds in monorepos with dozens of packages
  • The composite: true flag is mandatory for referenced projects and forces stricter settings that ensure reliable incremental builds, including generating declaration files that downstream projects consume
  • Proper dependency ordering through the references array prevents circular dependencies at compile time and allows TypeScript to build only changed packages and their dependents, not your entire codebase

Introduction to Project References

Managing a TypeScript monorepo without project references is painful. Every file change triggers a full rebuild of your entire codebase. Your IDE crawls as it tries to type-check thousands of files simultaneously. Circular dependencies between packages go undetected until runtime. Build times balloon from seconds to minutes as your codebase grows.

Project references solve these problems by letting TypeScript understand the dependency graph between your packages. Instead of treating your monorepo as one massive compilation unit, TypeScript builds each package separately and tracks dependencies between them. This enables incremental builds where only changed packages and their dependents get rebuilt, dramatically improving both build performance and IDE responsiveness.

The feature also enforces architectural boundaries. When package A references package B, TypeScript ensures B is built first and prevents circular dependencies. You get compile-time guarantees about your dependency structure instead of discovering problems at runtime.

Setting Up Your First Project Reference

Let’s convert a typical monorepo to use project references. Start with this structure:

my-monorepo/
├── packages/
│   ├── core/
│   │   ├── src/
│   │   │   └── index.ts
│   │   └── tsconfig.json
│   └── app/
│       ├── src/
│       │   └── main.ts
│       └── tsconfig.json
└── tsconfig.json

The core package contains shared utilities that app depends on. Here’s the configuration for packages/core/tsconfig.json:

{
  "compilerOptions": {
    "composite": true,
    "declaration": true,
    "declarationMap": true,
    "outDir": "./dist",
    "rootDir": "./src"
  },
  "include": ["src/**/*"]
}

The composite: true flag is critical. It enables project references and enforces several requirements: declaration must be true (so other projects can consume type information), and you must specify either rootDir or an include/files list. The declarationMap option generates source maps for declaration files, improving IDE navigation.

Now configure packages/app/tsconfig.json to reference core:

{
  "compilerOptions": {
    "composite": true,
    "declaration": true,
    "outDir": "./dist",
    "rootDir": "./src"
  },
  "references": [
    { "path": "../core" }
  ],
  "include": ["src/**/*"]
}

The references array tells TypeScript that this project depends on core. TypeScript will ensure core is built before app and will use core’s declaration files for type checking.

Create a root tsconfig.json that references both projects:

{
  "files": [],
  "references": [
    { "path": "./packages/core" },
    { "path": "./packages/app" }
  ]
}

This solution file doesn’t compile anything itself—it just orchestrates building the entire workspace.

Build Modes and the --build Flag

Standard tsc doesn’t understand project references. You must use tsc --build (or tsc -b):

# Build the entire workspace
tsc -b

# Build specific projects
tsc -b packages/app

# Clean build artifacts
tsc -b --clean

# Force rebuild everything
tsc -b --force

# Watch mode for development
tsc -b --watch

The build mode tracks dependencies in .tsbuildinfo files. These JSON files contain timestamps and hashes that let TypeScript determine what needs rebuilding:

# After first build
$ ls packages/core/
dist/  src/  tsconfig.json  tsconfig.tsbuildinfo

# Modify core/src/index.ts
$ touch packages/core/src/index.ts

# Only core and app rebuild (not other packages)
$ tsc -b

The performance difference is dramatic. In a monorepo with 20 packages, changing one file might trigger a full rebuild taking 45 seconds without project references. With project references, only the changed package and its 2 dependents rebuild in 3 seconds.

The --verbose flag shows exactly what TypeScript is doing:

$ tsc -b --verbose
[12:00:00] Projects in this build: 
    * packages/core/tsconfig.json
    * packages/app/tsconfig.json

[12:00:00] Project 'packages/core/tsconfig.json' is out of date because output file 'packages/core/dist/index.d.ts' does not exist

[12:00:00] Building project '/path/to/packages/core/tsconfig.json'...

[12:00:01] Project 'packages/app/tsconfig.json' is up to date, skipping

Structuring Dependencies Between Projects

For complex dependency chains, structure your references carefully. Here’s a three-tier architecture:

packages/
├── utils/           # Pure utilities, no dependencies
├── services/        # Business logic, depends on utils
└── app/            # Application, depends on services

The utils package has no references:

{
  "compilerOptions": {
    "composite": true,
    "declaration": true,
    "outDir": "./dist",
    "rootDir": "./src"
  }
}

The services package references utils:

{
  "compilerOptions": {
    "composite": true,
    "declaration": true,
    "outDir": "./dist",
    "rootDir": "./src",
    "baseUrl": ".",
    "paths": {
      "@myapp/utils": ["../utils/src"]
    }
  },
  "references": [
    { "path": "../utils" }
  ]
}

The paths mapping lets you import from source during development. TypeScript automatically rewrites these to use declaration files from dist during builds.

The app package references services (which transitively includes utils):

{
  "compilerOptions": {
    "composite": true,
    "declaration": true,
    "outDir": "./dist",
    "rootDir": "./src",
    "baseUrl": ".",
    "paths": {
      "@myapp/utils": ["../utils/src"],
      "@myapp/services": ["../services/src"]
    }
  },
  "references": [
    { "path": "../services" }
  ]
}

You only need to reference direct dependencies. TypeScript handles transitive dependencies automatically.

Common Patterns and Best Practices

Organize your package.json scripts for efficient builds:

{
  "scripts": {
    "build": "tsc -b",
    "build:force": "tsc -b --force",
    "clean": "tsc -b --clean",
    "dev": "tsc -b --watch",
    "build:core": "tsc -b packages/core",
    "build:app": "tsc -b packages/app"
  },
  "workspaces": [
    "packages/*"
  ]
}

For npm/pnpm/yarn workspaces, ensure your package dependencies match your TypeScript references:

// packages/app/package.json
{
  "name": "@myapp/app",
  "dependencies": {
    "@myapp/core": "workspace:*"
  }
}

The workspace:* protocol (pnpm/yarn) or file: protocol (npm) ensures package managers link local packages correctly.

Prevent circular dependencies by organizing packages in layers:

  1. Foundation layer: Pure utilities, no internal dependencies
  2. Domain layer: Business logic, depends only on foundation
  3. Application layer: Apps and APIs, depends on domain and foundation
  4. Infrastructure layer: Database, external APIs, depends on domain

Never allow lower layers to depend on higher layers. TypeScript will catch violations at compile time.

Troubleshooting and Gotchas

The most common issue is forgetting composite: true. This fails silently until you try to reference the project:

error TS6305: Output file 'packages/core/dist/index.d.ts' has not been built from source file 'packages/core/src/index.ts'.

Fix: Add "composite": true to the referenced project’s tsconfig.json.

Another frequent problem is incorrect build order. If you reference a project that hasn’t been built:

# WRONG - builds app before core
tsc -b packages/app packages/core

# RIGHT - TypeScript determines order automatically
tsc -b packages/app

Always let TypeScript resolve the build order by building from the root or from the final dependent.

Path resolution can be tricky. This fails:

// packages/app/src/main.ts
import { util } from '@myapp/core'; // Error: Cannot find module

Fix: Add path mappings to compilerOptions:

{
  "compilerOptions": {
    "baseUrl": ".",
    "paths": {
      "@myapp/core": ["../core/src"]
    }
  }
}

Some IDEs struggle with project references. VS Code works well, but you may need to reload the window after changing references. WebStorm requires enabling “TypeScript Language Service” in preferences.

Finally, remember that composite projects must specify rootDir or have explicit include/files arrays. This prevents accidentally including files from outside your source directory.

Project references transform TypeScript monorepos from unwieldy build nightmares into fast, maintainable systems. The initial setup takes effort, but the payoff in build performance and architectural clarity is worth it for any codebase with multiple packages.

Liked this? There's more.

Every week: one practical technique, explained simply, with code you can use immediately.