TypeScript — Project References
Project references (TypeScript 3.0+) allow you to split a large codebase into smaller TypeScript projects that reference each other. This enables incremental compilation — only changed projects are rebuilt — dramatically improving build times in monorepos and large codebases.
A composite project is a TypeScript project that can be referenced by other projects. It requires specific settings: composite: true, declaration: true, and declarationMap: true (recommended).
| 1 | { |
| 2 | "compilerOptions": { |
| 3 | "composite": true, |
| 4 | "declaration": true, |
| 5 | "declarationMap": true, |
| 6 | "outDir": "./dist", |
| 7 | "rootDir": "./src", |
| 8 | "target": "ES2022", |
| 9 | "module": "ESNext", |
| 10 | "moduleResolution": "bundler", |
| 11 | "strict": true |
| 12 | }, |
| 13 | "include": ["src"] |
| 14 | } |
The composite flag tells TypeScript that this project is a unit of compilation. It enforces that all input files are included in the include or files array, and that declaration is enabled.
| 1 | // packages/shared/src/utils.ts |
| 2 | export function formatDate(date: Date): string { |
| 3 | return date.toISOString().split("T")[0]; |
| 4 | } |
| 5 | |
| 6 | export function clamp(value: number, min: number, max: number): number { |
| 7 | return Math.min(Math.max(value, min), max); |
| 8 | } |
| 9 | |
| 10 | export type Result<T, E = Error> = |
| 11 | | { ok: true; value: T } |
| 12 | | { ok: false; error: E }; |
| 13 | |
| 14 | export function tryCatch<T>(fn: () => T): Result<T> { |
| 15 | try { |
| 16 | return { ok: true, value: fn() }; |
| 17 | } catch (e) { |
| 18 | return { ok: false, error: e as Error }; |
| 19 | } |
| 20 | } |
A project declares its dependencies using the references array in tsconfig.json. Each reference points to the directory of another composite project.
| 1 | { |
| 2 | "compilerOptions": { |
| 3 | "composite": true, |
| 4 | "declaration": true, |
| 5 | "declarationMap": true, |
| 6 | "outDir": "./dist", |
| 7 | "rootDir": "./src", |
| 8 | "target": "ES2022", |
| 9 | "module": "ESNext", |
| 10 | "moduleResolution": "bundler", |
| 11 | "strict": true, |
| 12 | "paths": { |
| 13 | "@shared/*": ["../shared/src/*"] |
| 14 | } |
| 15 | }, |
| 16 | "references": [ |
| 17 | { "path": "../shared" } |
| 18 | ], |
| 19 | "include": ["src"] |
| 20 | } |
| 1 | // packages/api/src/server.ts |
| 2 | import { formatDate, clamp } from "@shared/utils"; |
| 3 | import type { Result } from "@shared/utils"; |
| 4 | |
| 5 | interface User { |
| 6 | id: number; |
| 7 | name: string; |
| 8 | createdAt: Date; |
| 9 | } |
| 10 | |
| 11 | function getUser(id: number): Result<User> { |
| 12 | if (id <= 0) { |
| 13 | return { ok: false, error: new Error("Invalid ID") }; |
| 14 | } |
| 15 | return { |
| 16 | ok: true, |
| 17 | value: { |
| 18 | id, |
| 19 | name: "Alice", |
| 20 | createdAt: new Date(), |
| 21 | }, |
| 22 | }; |
| 23 | } |
| 24 | |
| 25 | function formatUser(user: User): string { |
| 26 | return `${user.name} (joined ${formatDate(user.createdAt)})`; |
| 27 | } |
| 28 | |
| 29 | const result = getUser(1); |
| 30 | if (result.ok) { |
| 31 | console.log(formatUser(result.value)); |
| 32 | } |
best practice
The --build flag (tsc -b) enables project reference awareness. It builds each project in dependency order, skips up-to-date projects, and produces declaration files for downstream consumption.
| 1 | # Build a project and all its dependencies |
| 2 | tsc -b packages/api |
| 3 | |
| 4 | # Build everything — respects dependency graph |
| 5 | tsc -b |
| 6 | |
| 7 | # Build with verbose output (see what's being built) |
| 8 | tsc -b --verbose |
| 9 | |
| 10 | # Watch mode — rebuild on changes |
| 11 | tsc -b --watch |
| 12 | |
| 13 | # Force rebuild (ignore cache) |
| 14 | tsc -b --force |
| 15 | |
| 16 | # Clean build — delete all output directories |
| 17 | tsc -b --clean |
| 18 | |
| 19 | # Build a specific project |
| 20 | tsc -b packages/shared |
| 21 | tsc -b packages/api |
| 1 | { |
| 2 | "references": [ |
| 3 | { "path": "./packages/shared" }, |
| 4 | { "path": "./packages/api" }, |
| 5 | { "path": "./packages/web" } |
| 6 | ] |
| 7 | } |
The root tsconfig.json can be a solution file that lists all projects. Running tsc -b from the root builds everything in the correct order.
Project references combined with --buildmode enable incremental compilation. TypeScript tracks which projects have changed and only rebuilds what's necessary.
| 1 | { |
| 2 | "compilerOptions": { |
| 3 | "composite": true, |
| 4 | "declaration": true, |
| 5 | "incremental": true, |
| 6 | "tsBuildInfoFile": "./.tsbuildinfo", |
| 7 | "outDir": "./dist", |
| 8 | "rootDir": "./src", |
| 9 | "target": "ES2022", |
| 10 | "module": "ESNext", |
| 11 | "moduleResolution": "bundler" |
| 12 | } |
| 13 | } |
The tsBuildInfoFile stores compilation state. On subsequent builds, TypeScript checks timestamps and content hashes to skip unchanged files. For large monorepos, this can reduce build times from minutes to seconds.
| 1 | # First build — compiles everything |
| 2 | tsc -b --verbose |
| 3 | # [12:00:01] Projects in this build: |
| 4 | # * packages/shared/tsconfig.json |
| 5 | # * packages/api/tsconfig.json |
| 6 | # * packages/web/tsconfig.json |
| 7 | # [12:00:01] Project 'packages/shared/tsconfig.json' is up to date |
| 8 | # [12:00:01] Project 'packages/api/tsconfig.json' is up to date |
| 9 | # [12:00:01] Project 'packages/web/tsconfig.json' is up to date |
| 10 | |
| 11 | # Change a file in shared — only shared and dependents rebuild |
| 12 | tsc -b --verbose |
| 13 | # [12:00:05] Project 'packages/shared/tsconfig.json' is out of date |
| 14 | # [12:00:05] Building project 'packages/shared/tsconfig.json' |
| 15 | # [12:00:06] Project 'packages/api/tsconfig.json' is out of date |
| 16 | # [12:00:06] Building project 'packages/api/tsconfig.json' |
| 17 | # [12:00:07] Project 'packages/web/tsconfig.json' is out of date |
| 18 | # [12:00:07] Building project 'packages/web/tsconfig.json' |
| 19 | |
| 20 | # Unchanged build — everything is cached |
| 21 | tsc -b --verbose |
| 22 | # [12:00:10] All projects are up to date |
info
In a monorepo, project references create a dependency graph. Each package is a composite project, and the root tsconfig.json ties them together. This pattern scales from small teams to large organizations.
| 1 | # Typical monorepo structure |
| 2 | my-project/ |
| 3 | ├── tsconfig.json # Solution file (references all projects) |
| 4 | ├── packages/ |
| 5 | │ ├── shared/ |
| 6 | │ │ ├── tsconfig.json # composite: true |
| 7 | │ │ └── src/ |
| 8 | │ │ └── index.ts |
| 9 | │ ├── api/ |
| 10 | │ │ ├── tsconfig.json # references: [shared] |
| 11 | │ │ └── src/ |
| 12 | │ │ └── server.ts |
| 13 | │ └── web/ |
| 14 | │ ├── tsconfig.json # references: [shared] |
| 15 | │ └── src/ |
| 16 | │ └── app.tsx |
| 1 | { |
| 2 | "compilerOptions": { |
| 3 | "composite": true, |
| 4 | "declaration": true, |
| 5 | "outDir": "./dist", |
| 6 | "rootDir": "./src", |
| 7 | "target": "ES2022", |
| 8 | "module": "ESNext", |
| 9 | "moduleResolution": "bundler", |
| 10 | "strict": true, |
| 11 | "paths": { |
| 12 | "@my-project/shared": ["../shared/src"] |
| 13 | } |
| 14 | }, |
| 15 | "references": [ |
| 16 | { "path": "../shared" } |
| 17 | ], |
| 18 | "include": ["src"] |
| 19 | } |
best practice
Each composite project outputs its compiled files and declaration files to its own outDir. Downstream projects consume the .d.ts files, not the source code.
| 1 | # After building with tsc -b |
| 2 | packages/ |
| 3 | ├── shared/ |
| 4 | │ └── dist/ |
| 5 | │ ├── index.js |
| 6 | │ ├── index.d.ts # ← consumed by api and web |
| 7 | │ ├── index.d.ts.map |
| 8 | │ ├── utils.js |
| 9 | │ ├── utils.d.ts # ← consumed by api and web |
| 10 | │ ├── utils.d.ts.map |
| 11 | │ └── .tsbuildinfo # ← build cache |
| 12 | ├── api/ |
| 13 | │ └── dist/ |
| 14 | │ ├── server.js |
| 15 | │ ├── server.d.ts |
| 16 | │ ├── server.d.ts.map |
| 17 | │ └── .tsbuildinfo |
| 18 | └── web/ |
| 19 | └── dist/ |
| 20 | ├── app.js |
| 21 | ├── app.d.ts |
| 22 | ├── app.d.ts.map |
| 23 | └── .tsbuildinfo |
Always use tsc -b for project references. Running plain tsc ignores project references and builds nothing. tsc -b is the only way to build with references.
Build order matters. tsc -b respects the dependency graph and builds projects in topological order. If you build manually, ensure dependencies are built first.
Use --watch for development. tsc -b --watch watches all projects and rebuilds affected ones on change. It's faster than running tsc -b repeatedly.
Keep outDir and rootDir consistent. Each project must have its own outDir. If two projects share the same outDir, their outputs will collide and cause confusing errors.
Never edit files in outDir. The output directory is managed by tsc. Manual changes are overwritten on the next build. If you need to customize output, use a build script after tsc.
Use composite with bundlers carefully. If you use esbuild or swc for production builds, keep composite + declaration for type-checking only. The bundler handles the actual output.
warning