Introduction
As TypeScript codebases grow, the monolithic tsconfig.json approach breaks down. Every invocation of tsc type-checks and emits the entire project — a process that can take minutes even with --noEmit in large monorepos. Worse, without module boundaries, any file can import any other file, creating tangled dependency graphs that are difficult to refactor. TypeScript Project References solve this by allowing you to split your codebase into independent sub-projects, each with its own tsconfig.json, enabling incremental builds, enforced API boundaries, and dramatically faster type-checking.
What Are Project References?
A project reference declares that one TypeScript project depends on another. Each sub-project must set composite: true in its tsconfig.json, and the consuming project lists its dependencies in the references array:
{
"compilerOptions": {
"composite": true,
"declaration": true,
"declarationMap": true,
"rootDir": "src",
"outDir": "dist"
}
}
{
"references": [
{ "path": "../packages/core" },
{ "path": "../packages/utils" }
]
}
When building, TypeScript first builds the referenced projects (in topological order), producing .d.ts and .js outputs that the parent project consumes. This enforces a clear, acyclic dependency graph — if package A references package B, then B cannot reference A. Any attempt to create circular dependencies produces a compile-time error.
Composite Projects and Build Mode
A composite project (composite: true) is a project designed to be referenced by other projects. It requires declaration: true, a rootDir, and an outDir. Enabling declarationMap: true is strongly recommended — it generates .d.ts.map files that allow editor tooling (like “Go to Definition”) to navigate directly to the original .ts source rather than the .d.ts declaration file.
The build mode command, tsc --build (or tsc -b), processes the entire project graph intelligently:
# Build all projects and their dependencies
tsc -b
# Force rebuild all projects
tsc -b --force
# Clean build outputs
tsc -b --clean
# Dry run — show what would be built
tsc -b --dry
Build mode uses .tsbuildinfo files to track file hashes and dependency relationships. On subsequent runs, it skips projects whose inputs haven’t changed, resulting in 50–90% reduction in build time for large monorepos. The incremental: true option is implied by composite, but you can control where the build info files are written using tsBuildInfoFile.
Monorepo Integration
Project References integrate naturally with monorepo tooling. A typical structure looks like this:
packages/
core/ tsconfig.json (composite: true)
utils/ tsconfig.json (composite: true)
apps/
web/ tsconfig.json (references: [core, utils])
api/ tsconfig.json (references: [core, utils])
tsconfig.base.json
Different monorepo tools offer varying levels of support:
| Tool | Support |
|---|---|
| npm/pnpm/yarn workspaces | Works well; declare references in root tsconfig.json |
| Turborepo | Native support via inputs and dependsOn |
| Nx | Auto-detects project references; parallelizes builds |
| Lerna | Manual configuration needed |
Declaration Maps and Developer Experience
One of the most impactful yet underused features is declaration maps. When you navigate to a type definition in a consumer project (e.g., VS Code’s “Go to Definition”), without declaration maps you land on the .d.ts file — which contains only type signatures, not implementation. With declarationMap: true, you jump directly to the original .ts source:
{
"compilerOptions": {
"declaration": true,
"declarationMap": true,
"sourceMap": true
}
}
Declaration maps have zero runtime overhead — they are only consumed by editor tooling. Combined with sourceMap: true, they provide a complete debugging experience from development through production.
CI Optimization
The real payoff of Project References comes in CI. By caching .tsbuildinfo files between runs (using @actions/cache or similar), you retain incremental build information across pipeline executions:
# GitHub Actions — cache .tsbuildinfo files
- uses: actions/cache@v3
with:
path: "**/*.tsbuildinfo"
key: tsbuildinfo-${{ runner.os }}-${{ hashFiles('**/tsconfig*.json') }}
For selective type-checking, use tsc -b --noEmit — this type-checks only the projects that have changed since the last build, rather than the entire codebase. Reserve --force for main branch builds; rely on incremental mode for pull request CI. For very large monorepos, tools like nx affected can further narrow the scope to projects directly impacted by a given change.
Conclusion
TypeScript Project References solve the scaling problem for large TypeScript codebases. Start by splitting your project into 2–3 logical packages — a core types package, a utilities package, and your application code. Use composite: true with declaration maps for good developer experience, leverage tsc -b for fast incremental builds, and cache .tsbuildinfo files in CI to extend those benefits across pipeline runs. Tools like Turborepo and Nx enhance project references with caching and parallel execution, making them even more powerful in monorepo setups.
