TypeScript Project References for Monorepos
Set up TypeScript project references to manage multi-package monorepos with incremental builds, clean boundaries, and fast compilation.
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 -bhandles 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.
Related articles
- TypeScript tsconfig.json Deep Dive: Every Option Explained
A comprehensive guide to tsconfig.json covering compiler options, module resolution, path aliases, project references, and recommended configurations.
- TypeScript TypeScript Generics: Advanced Patterns and Constraints
Master advanced TypeScript generics patterns including conditional constraints, recursive types, generic factories, and type-safe builder patterns with examples.
- TypeScript TypeScript Type Guards and Narrowing Explained
Learn how TypeScript type guards and control flow narrowing work, including typeof, instanceof, in, discriminated unions, and custom type predicates.
- TypeScript TypeScript Utility Types: The Complete Guide
A comprehensive guide to all built-in TypeScript utility types including Partial, Required, Pick, Omit, Record, Extract, Exclude, and more with practical examples.