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
A root tsconfig.json with "files": [] and only references acts as a solution file. Editors and tsc -b use it to typecheck the whole graph.
| 1 | { |
| 2 | "files": [], |
| 3 | "references": [ |
| 4 | { "path": "./packages/core" }, |
| 5 | { "path": "./packages/ui" }, |
| 6 | { "path": "./apps/web" } |
| 7 | ] |
| 8 | } |
info
| 1 | tsc -b |
| 2 | tsc -b --clean |
| 3 | tsc -b --force |
| 4 | tsc -b --verbose |
| 5 | tsc -b --pretty false |
| Flag | Use |
|---|---|
| --clean | Delete outputs from referenced projects |
| --force | Ignore cache; full rebuild |
| --watch | Incremental rebuild on change |
| 1 | { |
| 2 | "extends": "../../tsconfig.base.json", |
| 3 | "compilerOptions": { |
| 4 | "composite": true, |
| 5 | "declaration": true, |
| 6 | "declarationMap": true, |
| 7 | "outDir": "dist", |
| 8 | "rootDir": "src" |
| 9 | }, |
| 10 | "include": ["src"], |
| 11 | "references": [{ "path": "../core" }] |
| 12 | } |
warning
Cross-package paths aliases bypass the reference graph. Prefer workspace dependencies and project references so build order stays correct.
| 1 | // Good — package name resolved via node_modules / workspace link |
| 2 | import { Button } from "@acme/ui"; |
| 3 | |
| 4 | // Risky — path into another package's src |
| 5 | // import { Button } from "../../packages/ui/src/Button"; |
best practice
| 1 | { |
| 2 | "question": "Which options are required for a referenced project?", |
| 3 | "answer": ["composite", "declaration"], |
| 4 | "build": "tsc -b" |
| 5 | } |
note
Extra patterns for production code. Read one, type it from memory, then compare.
| 1 | export function invariant(cond: unknown, msg: string): asserts cond { |
| 2 | if (!cond) throw new Error(msg); |
| 3 | } |
| 4 | |
| 5 | export function unreachable(message = "unreachable"): never { |
| 6 | throw new Error(message); |
| 7 | } |
| 1 | export type Fn<A extends unknown[], R> = (...args: A) => R; |
| 2 | |
| 3 | export function once<A extends unknown[], R>(fn: Fn<A, R>): Fn<A, R> { |
| 4 | let called = false; |
| 5 | let result!: R; |
| 6 | return (...args: A) => { |
| 7 | if (!called) { |
| 8 | result = fn(...args); |
| 9 | called = true; |
| 10 | } |
| 11 | return result; |
| 12 | }; |
| 13 | } |
| 1 | export async function mapPool<T, R>( |
| 2 | items: readonly T[], |
| 3 | limit: number, |
| 4 | worker: (item: T, index: number) => Promise<R>, |
| 5 | ): Promise<R[]> { |
| 6 | const results: R[] = new Array(items.length); |
| 7 | let i = 0; |
| 8 | async function run() { |
| 9 | while (i < items.length) { |
| 10 | const idx = i++; |
| 11 | results[idx] = await worker(items[idx]!, idx); |
| 12 | } |
| 13 | } |
| 14 | await Promise.all(Array.from({ length: Math.min(limit, items.length) }, () => run())); |
| 15 | return results; |
| 16 | } |
info
| Helper | When to use |
|---|---|
| invariant / asserts | Narrow after runtime checks |
| unreachable / never | Exhaustiveness & dead ends |
| once | Lazy init without races in sync code |
| mapPool | Bounded async concurrency |
Community
Get help on Slack, Discord or VIP
Stuck on a guide? Join the community and ask.