Project References and Incremental Builds
When Single tsconfig Isn't Enough
Here's a problem you'll definitely hit as your codebase grows: a single tsconfig.json compiling everything becomes a bottleneck. A 200-file project compiles in seconds. A 2000-file monorepo? Minutes. Project references split the codebase into independently compilable units, enabling incremental builds that only recompile what changed.
Think of project references as building with LEGO sets instead of one giant pile. Without references, TypeScript dumps all your files into one pile and checks everything from scratch. With references, each package is a separate LEGO set (composite project) with its own box (build output). When you change one set, only that set gets rebuilt — the others use their already-assembled state from the box.
Enabling Incremental Builds
Good news: the simplest optimization doesn't even require project references:
{
"compilerOptions": {
"incremental": true,
"tsBuildInfoFile": ".tsbuildinfo"
}
}
This creates a .tsbuildinfo file that caches the previous compilation. On the next compile, TypeScript diffs against the cache and only rechecks changed files and their dependents. For single-project setups, this alone can cut build times significantly.
Composite Projects
A composite project is a TypeScript project that declares itself as a buildable unit:
// packages/shared/tsconfig.json
{
"compilerOptions": {
"composite": true,
"declaration": true,
"declarationMap": true,
"outDir": "dist",
"rootDir": "src"
},
"include": ["src"]
}
composite: true enables three things:
- Forces
declaration: true(generates.d.tsfiles) - Forces
rootDirto be set - Enables the project to be referenced by other projects
Other projects that reference a composite project read its .d.ts files instead of recompiling its source. This is the core optimization — downstream projects don't need to parse and type-check the source of their dependencies, just the pre-built declarations.
Project References
The root tsconfig.json references composite sub-projects:
// tsconfig.json (root)
{
"references": [
{ "path": "packages/shared" },
{ "path": "packages/server" },
{ "path": "packages/client" }
],
"files": [] // Root doesn't compile anything itself
}
Each sub-project can reference other sub-projects:
// packages/server/tsconfig.json
{
"compilerOptions": {
"composite": true,
"declaration": true,
"outDir": "dist",
"rootDir": "src"
},
"references": [
{ "path": "../shared" }
],
"include": ["src"]
}
Build Mode
This is important — you need to use tsc --build (or tsc -b) to compile project references, not plain tsc:
# Build all projects in dependency order
tsc --build
# Build a specific project and its dependencies
tsc --build packages/server
# Force rebuild everything
tsc --build --force
# Clean build outputs
tsc --build --clean
# Watch mode with project references
tsc --build --watch
# Verbose output (shows what's being rebuilt)
tsc --build --verbose
How tsc --build determines what to rebuild
When you run tsc --build, TypeScript:
- Reads the
referencesarray and builds a dependency graph of projects - Topologically sorts the graph (shared → server → client)
- For each project, compares the
.tsbuildinfofile against current source files - If any source file is newer than the build output, recompiles that project
- If a dependency was rebuilt, recompiles all downstream projects
The key optimization: if packages/shared hasn't changed, TypeScript skips it entirely. If only packages/client changed, only the client is recompiled — but it reads the pre-built .d.ts files from packages/shared instead of recompiling shared source.
Monorepo Setup
A typical monorepo with project references:
monorepo/
├── tsconfig.json # Root — references all packages
├── packages/
│ ├── shared/
│ │ ├── tsconfig.json # composite: true
│ │ ├── src/
│ │ │ ├── types.ts
│ │ │ └── utils.ts
│ │ └── dist/ # Build output + .d.ts files
│ ├── api/
│ │ ├── tsconfig.json # references: [shared]
│ │ └── src/
│ └── web/
│ ├── tsconfig.json # references: [shared]
│ └── src/
// packages/shared/tsconfig.json
{
"compilerOptions": {
"composite": true,
"declaration": true,
"declarationMap": true,
"sourceMap": true,
"outDir": "dist",
"rootDir": "src",
"strict": true,
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "bundler"
},
"include": ["src"]
}
// packages/web/tsconfig.json
{
"compilerOptions": {
"composite": true,
"declaration": true,
"outDir": "dist",
"rootDir": "src",
"strict": true,
"jsx": "react-jsx",
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "bundler"
},
"references": [
{ "path": "../shared" }
],
"include": ["src"]
}
With project references, you MUST import from the package name (as configured in package.json), not relative paths across project boundaries:
// WRONG — relative import crossing project boundary
import { User } from "../../shared/src/types";
// CORRECT — import from the package
import { User } from "@myorg/shared";This requires proper exports configuration in each package's package.json and correct path resolution. Most monorepo tools (Turborepo, Nx, pnpm workspaces) handle this automatically.
Production Scenario: Full Monorepo Configuration
// Root tsconfig.json
{
"compilerOptions": {
"strict": true,
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "bundler",
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"isolatedModules": true,
"verbatimModuleSyntax": true
}
}
// packages/shared/tsconfig.json
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"composite": true,
"declaration": true,
"declarationMap": true,
"outDir": "dist",
"rootDir": "src"
},
"include": ["src"]
}
// packages/shared/package.json
{
"name": "@myorg/shared",
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js"
}
},
"files": ["dist"]
}
| What developers do | What they should do |
|---|---|
| Using relative imports across project reference boundaries Project references compile independently. Cross-boundary relative imports bypass the declaration boundary. | Import from package names: import { X } from '@myorg/shared', not '../../shared/src/x' |
| Forgetting composite: true on referenced projects composite enables declaration generation and build tracking — without it, tsc --build can't manage the project | Every project listed in another project's references must have composite: true |
| Not running tsc --build and using tsc directly with project references Plain tsc doesn't understand project references. tsc --build handles dependency ordering and incremental compilation. | Always use tsc --build (or tsc -b) when working with project references |
| Committing .tsbuildinfo and dist/ for composite projects to git These files are generated by tsc --build and change frequently. Committing them causes merge conflicts. | Add .tsbuildinfo and dist/ to .gitignore — they're build artifacts |
Challenge: Configure a 3-Package Monorepo
// Set up TypeScript project references for this monorepo structure:
//
// packages/
// types/ — shared TypeScript types (no runtime code)
// core/ — business logic (depends on types)
// web/ — React app (depends on types AND core)
//
// Requirements:
// 1. All packages use strict mode
// 2. types/ generates .d.ts files only (declarationOnly)
// 3. core/ depends on types/
// 4. web/ depends on both types/ and core/
// 5. Root tsconfig.json references all three
//
// Write all four tsconfig.json files:
// tsconfig.json (root)
{
"compilerOptions": {
"strict": true,
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "bundler",
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true
},
"references": [
{ "path": "packages/types" },
{ "path": "packages/core" },
{ "path": "packages/web" }
],
"files": []
}
// packages/types/tsconfig.json
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"composite": true,
"declaration": true,
"emitDeclarationOnly": true,
"outDir": "dist",
"rootDir": "src"
},
"include": ["src"]
}
// packages/core/tsconfig.json
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"composite": true,
"declaration": true,
"outDir": "dist",
"rootDir": "src"
},
"references": [
{ "path": "../types" }
],
"include": ["src"]
}
// packages/web/tsconfig.json
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"composite": true,
"declaration": true,
"outDir": "dist",
"rootDir": "src",
"jsx": "react-jsx"
},
"references": [
{ "path": "../types" },
{ "path": "../core" }
],
"include": ["src"]
}
The key: each package uses extends for shared options, composite: true for independent compilation, and references for type-safe cross-package imports. Run tsc --build from the root to compile all packages in the correct dependency order.
- 1composite: true + declaration: true makes a project referenceable — downstream reads .d.ts instead of source
- 2Always use tsc --build (not plain tsc) with project references — it handles dependency ordering and incremental compilation
- 3Import across project boundaries via package names, not relative paths
- 4incremental: true alone (without references) still provides significant speedup via .tsbuildinfo caching
- 5Add .tsbuildinfo and composite project dist/ directories to .gitignore — they're build artifacts