Skip to content
Codeloom
TypeScript

TypeScript Project References for Monorepos

Set up TypeScript project references to manage multi-package monorepos with incremental builds, clean boundaries, and fast compilation.

·6 min read · By Codeloom
Advanced 10 min read

What you'll learn

  • How TypeScript project references split a codebase into build units
  • Configuring composite projects and the references array
  • Using tsc --build for incremental compilation
  • Structuring a monorepo with shared packages

Prerequisites

None — this post is self-contained.

As a TypeScript codebase grows, compile times increase and package boundaries blur. Project references let you split a single codebase into multiple TypeScript projects that the compiler understands as a dependency graph. Each project compiles independently, caches its output, and only rebuilds when its source changes. This is the foundation for fast, correct builds in monorepos.

The Problem With a Single tsconfig

In a monorepo with packages like shared, api, and web, a single tsconfig.json that includes everything has several problems. The compiler processes all files on every build, even when only one package changed. There is no enforced boundary between packages, so api can accidentally import internal files from web. And editor performance degrades as the project grows because the language server loads everything.

Enabling Composite Projects

Each package becomes a composite project by setting composite: true in its tsconfig.json. This tells the compiler the project can be referenced by other projects.

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

The composite flag enforces two requirements: declaration must be enabled (so downstream projects can consume types without re-parsing source), and every source file must be matched by the include or files array.

Adding References

A project that depends on another lists it in the references array:

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

The path points to the directory containing the referenced project’s tsconfig.json. When api is compiled, TypeScript uses the declaration files from shared/dist rather than re-compiling shared source files.

The Root tsconfig

A root tsconfig.json ties all projects together:

// tsconfig.json (root)
{
  "files": [],
  "references": [
    { "path": "packages/shared" },
    { "path": "packages/api" },
    { "path": "packages/web" }
  ]
}

Setting files to an empty array ensures this config does not compile anything itself. It only orchestrates the referenced projects.

Building With tsc —build

The --build flag (or -b) is the key command for project references:

tsc --build

This walks the dependency graph, determines which projects are out of date by comparing source file timestamps against stored build info, and compiles only what changed. On subsequent builds, only modified projects recompile.

Useful flags:

tsc -b --verbose      # Shows which projects are up to date
tsc -b --clean        # Removes all build outputs
tsc -b --force        # Rebuilds everything regardless of timestamps
tsc -b --watch        # Watches all projects for changes

A Complete Monorepo Structure

Here is a practical layout:

monorepo/
  packages/
    shared/
      src/
        index.ts
        types.ts
      tsconfig.json
      package.json
    api/
      src/
        index.ts
        routes.ts
      tsconfig.json
      package.json
    web/
      src/
        index.ts
        App.tsx
      tsconfig.json
      package.json
  tsconfig.json

The shared package exports domain types and utility functions:

// packages/shared/src/types.ts
export interface User {
  id: string;
  email: string;
  role: 'admin' | 'member';
}

export interface ApiResponse<T> {
  data: T;
  error: string | null;
  timestamp: number;
}
// packages/shared/src/index.ts
export * from './types';

export function formatDate(date: Date): string {
  return date.toISOString().split('T')[0];
}

The api package references shared:

// packages/api/src/routes.ts
import { User, ApiResponse } from '@monorepo/shared';

export function getUser(id: string): ApiResponse<User> {
  return {
    data: { id, email: 'user@example.com', role: 'member' },
    error: null,
    timestamp: Date.now(),
  };
}

Path Mapping With Package Names

To import @monorepo/shared instead of relative paths like ../../shared/src, configure paths in the consuming project and set up the package manager’s workspace resolution:

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

In packages/shared/package.json:

{
  "name": "@monorepo/shared",
  "main": "./dist/index.js",
  "types": "./dist/index.d.ts"
}

With npm, pnpm, or yarn workspaces, the package manager symlinks @monorepo/shared into node_modules, and TypeScript resolves the types through the reference.

Enforcing Boundaries

Project references enforce that a project can only import from its declared references. If web does not list api in its references, importing from api produces a compiler error. This prevents circular dependencies and accidental coupling.

To verify boundaries are respected:

tsc -b --verbose 2>&1 | grep "error"

Any cross-boundary import that violates the reference graph shows up as a compile error.

Incremental Builds With Build Info

Each composite project generates a .tsbuildinfo file alongside its output. This file records the state of the last successful build: file hashes, dependency relationships, and compiler options. On the next tsc -b, the compiler reads this file and skips projects whose inputs have not changed.

The speedup is significant. In a monorepo with 50 packages, changing one file in one package results in recompiling only that package and its downstream dependents, not the entire codebase.

Integration With Build Tools

Project references work alongside bundlers and task runners. In a typical setup:

  • tsc -b handles type checking and declaration generation.
  • A bundler like esbuild, Vite, or webpack handles the actual JavaScript output.
  • A task runner like Turborepo or Nx orchestrates the dependency graph.

When using a bundler, set emitDeclarationOnly: true in each project so tsc -b only generates declarations and build info, leaving JavaScript transpilation to the faster tool:

{
  "compilerOptions": {
    "composite": true,
    "declaration": true,
    "emitDeclarationOnly": true
  }
}

Common Mistakes

Forgetting composite: true in a referenced project produces a confusing error about the project not being configured correctly. Every project in the reference chain must be composite.

Omitting declaration: true breaks downstream projects because they cannot consume types without declaration files.

Circular references are not allowed. If A references B and B references A, the compiler reports an error. Extract shared code into a third project that both can reference.

Wrap Up

TypeScript project references turn a monorepo into a graph of independently compilable units. Each project declares its dependencies through the references array, enables composite mode, and emits declaration files for downstream consumers. The tsc --build command walks this graph, recompiles only what changed, and enforces clean package boundaries. Combined with workspace-aware package managers and build orchestrators, project references make large TypeScript codebases fast to compile and safe to refactor.