Project References & Monorepos
As TypeScript codebases grow, tsc slows down because it re-checks the entire project on every build. In a monorepo with multiple packages, tsc is even worse — it knows nothing about the boundaries between packages and must process all files together. Project references solve both problems: they let TypeScript understand your package graph and build only what changed.
What Problem Do Project References Solve?
Imagine you have three packages: packages/core, packages/utils, and packages/web. Without project references, your options are:
Run tsc in each package separately — no cross-package type checking
Point all packages at a single root tsconfig — merges all files, defeats isolation
Use a monorepo tool (Turborepo, Nx) without telling TypeScript about the graph — TypeScript still rebuilds everything
With project references, TypeScript understands the dependency graph. It knows that web depends on utils which depends on core. It can:
Build packages in dependency order automatically
Skip unchanged packages entirely using cached .d.ts files
Report errors that cross package boundaries correctly
Enable incremental builds that only process changed packages
Keep each package's types isolated — web cannot accidentally use core's internal types
The composite Flag
Every package that is referenced by another must set "composite": true. This enables three requirements that project references depend on:
rootDir must be set (TypeScript enforces it)
declaration must be true (so other packages can use the .d.ts output)
A .tsbuildinfo file is generated (so incremental builds work)
// packages/utils/tsconfig.json
{
"compilerOptions": {
"composite": true, // enables project reference support
"declaration": true, // auto-enabled by composite
"declarationMap": true, // "Go to Definition" jumps to .ts source
"sourceMap": true,
"strict": true,
"target": "ES2020",
"module": "CommonJS",
"moduleResolution": "node",
"outDir": "./dist",
"rootDir": "./src" // required when composite: true
},
"include": ["src/**/*"]
}composite: true automatically implies declaration: true. You can omit it, but being explicit is clearer for readers of the config.The references Array
A package declares its dependencies via the references array. Each entry is an object with a path pointing to the directory (or tsconfig file) of the dependency:
// packages/web/tsconfig.json
{
"compilerOptions": {
"composite": true,
"strict": true,
"target": "ES2020",
"module": "CommonJS",
"moduleResolution": "node",
"outDir": "./dist",
"rootDir": "./src"
},
"include": ["src/**/*"],
"references": [
{ "path": "../utils" }, // web depends on utils
{ "path": "../core" } // web also depends on core
]
}// packages/utils/tsconfig.json
{
"compilerOptions": {
"composite": true,
"strict": true,
"target": "ES2020",
"module": "CommonJS",
"moduleResolution": "node",
"outDir": "./dist",
"rootDir": "./src"
},
"include": ["src/**/*"],
"references": [
{ "path": "../core" } // utils depends on core
]
}// packages/core/tsconfig.json — no dependencies on other local packages
{
"compilerOptions": {
"composite": true,
"strict": true,
"target": "ES2020",
"module": "CommonJS",
"moduleResolution": "node",
"outDir": "./dist",
"rootDir": "./src"
},
"include": ["src/**/*"]
// no "references" — core has no local package dependencies
}tsc --build Mode
The --build flag (or -b) activates project references mode. Without it, TypeScript ignores the references array entirely:
# Build a single project and all its dependencies in order tsc --build packages/web # Build from the root tsconfig (which references all packages) tsc --build # Build without emitting files (type-check only) tsc --build --noEmit # Rebuild from scratch — ignore cached .tsbuildinfo tsc --build --force # Watch mode — rebuild on file changes tsc --build --watch # See what would be built without actually building tsc --build --dry # Clean all outputs and .tsbuildinfo files tsc --build --clean
# Example output for tsc --build packages/web
[12:00:01] Projects in this build:
* packages/core/tsconfig.json
* packages/utils/tsconfig.json
* packages/web/tsconfig.json
[12:00:01] Project 'packages/core/tsconfig.json' is up to date
[12:00:01] Building project 'packages/utils/tsconfig.json'...
[12:00:02] Building project 'packages/web/tsconfig.json'...
[12:00:03] Build complete.package.json at the root: "build": "tsc --build". The root tsconfig.json references all packages, so one command builds everything in the right order.Complete Monorepo Structure
Here is a realistic monorepo with three packages: core (shared types), utils (utility functions using core types), and app (the application). The structure looks like this:
my-monorepo/ ├── package.json ├── tsconfig.json # root — references all packages ├── packages/ │ ├── core/ │ │ ├── package.json │ │ ├── tsconfig.json │ │ └── src/ │ │ ├── index.ts │ │ └── types.ts │ ├── utils/ │ │ ├── package.json │ │ ├── tsconfig.json │ │ └── src/ │ │ ├── index.ts │ │ └── format.ts │ └── app/ │ ├── package.json │ ├── tsconfig.json │ └── src/ │ └── main.ts └── node_modules/
Root tsconfig.json
The root tsconfig.json is a solution file — it contains no compilerOptions for files, only a references list that names every package. It is what you pass to tsc --build:
// tsconfig.json (root — solution file)
{
"files": [], // important: no files here — this is a pure solution file
"references": [
{ "path": "packages/core" },
{ "path": "packages/utils" },
{ "path": "packages/app" }
]
}"files": [] and omit include in the root solution file. If you accidentally include files, TypeScript will try to type-check them without the settings from the package-level tsconfigs, producing confusing errors.The core Package
// packages/core/tsconfig.json
{
"compilerOptions": {
"composite": true,
"strict": true,
"target": "ES2020",
"module": "CommonJS",
"moduleResolution": "node",
"declaration": true,
"declarationMap": true,
"sourceMap": true,
"outDir": "./dist",
"rootDir": "./src"
},
"include": ["src/**/*"]
}// packages/core/src/types.ts
export interface User {
id: string;
name: string;
email: string;
role: 'admin' | 'editor' | 'viewer';
createdAt: Date;
}
export interface ApiResponse<T> {
data: T;
success: boolean;
message?: string;
timestamp: string;
}
export type UserId = string & { readonly __brand: unique symbol };
export function createUserId(id: string): UserId {
return id as UserId;
}// packages/core/src/index.ts
export type { User, ApiResponse, UserId } from './types';
export { createUserId } from './types';// packages/core/package.json
{
"name": "@myapp/core",
"version": "1.0.0",
"main": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": {
".": {
"require": "./dist/index.js",
"import": "./dist/index.js",
"types": "./dist/index.d.ts"
}
}
}The utils Package
// packages/utils/tsconfig.json
{
"compilerOptions": {
"composite": true,
"strict": true,
"target": "ES2020",
"module": "CommonJS",
"moduleResolution": "node",
"declaration": true,
"declarationMap": true,
"sourceMap": true,
"outDir": "./dist",
"rootDir": "./src"
},
"include": ["src/**/*"],
"references": [
{ "path": "../core" }
]
}// packages/utils/src/format.ts
import type { User, ApiResponse } from '@myapp/core';
export function formatUserName(user: User): string {
return `${user.name} <${user.email}>`;
}
export function formatApiResponse<T>(
data: T,
message?: string
): ApiResponse<T> {
return {
data,
success: true,
message,
timestamp: new Date().toISOString(),
};
}
export function formatDate(date: Date): string {
return date.toLocaleDateString('en-US', {
year: 'numeric',
month: 'long',
day: 'numeric',
});
}// packages/utils/src/index.ts
export { formatUserName, formatApiResponse, formatDate } from './format';// packages/utils/package.json
{
"name": "@myapp/utils",
"version": "1.0.0",
"main": "./dist/index.js",
"types": "./dist/index.d.ts",
"dependencies": {
"@myapp/core": "*"
}
}The app Package
// packages/app/tsconfig.json
{
"compilerOptions": {
"composite": true,
"strict": true,
"target": "ES2020",
"module": "CommonJS",
"moduleResolution": "node",
"declaration": true,
"declarationMap": true,
"sourceMap": true,
"outDir": "./dist",
"rootDir": "./src"
},
"include": ["src/**/*"],
"references": [
{ "path": "../core" },
{ "path": "../utils" }
]
}// packages/app/src/main.ts
import { createUserId } from '@myapp/core';
import type { User } from '@myapp/core';
import { formatUserName, formatApiResponse, formatDate } from '@myapp/utils';
const user: User = {
id: createUserId('user-001'),
name: 'Alice Smith',
email: 'alice@example.com',
role: 'admin',
createdAt: new Date('2024-01-15'),
};
console.log(formatUserName(user));
console.log(formatDate(user.createdAt));
const response = formatApiResponse(user, 'User loaded successfully');
console.log(JSON.stringify(response, null, 2));Alice Smith <alice@example.com>
January 15, 2024
{
"data": {
"id": "user-001",
"name": "Alice Smith",
"email": "alice@example.com",
"role": "admin",
"createdAt": "2024-01-15T00:00:00.000Z"
},
"success": true,
"message": "User loaded successfully",
"timestamp": "2024-06-01T12:00:00.000Z"
}Path Aliases Across Packages
In a monorepo, packages import each other by package name (e.g. @myapp/core). TypeScript resolves these through the paths option in tsconfig.json, pointing to the package's source or compiled output:
// packages/app/tsconfig.json — paths pointing to source
{
"compilerOptions": {
"composite": true,
"strict": true,
"target": "ES2020",
"module": "CommonJS",
"moduleResolution": "node",
"baseUrl": ".",
"paths": {
"@myapp/core": ["../core/src/index.ts"],
"@myapp/utils": ["../utils/src/index.ts"]
},
"outDir": "./dist",
"rootDir": "./src"
},
"include": ["src/**/*"],
"references": [
{ "path": "../core" },
{ "path": "../utils" }
]
}paths pointing to .ts source files means TypeScript reads the original source for cross-package type checking, giving you accurate "Go to Definition" jumps even before the packages are built. The references array still controls build order — both are needed.Alternatively, you can use npm workspaces and point paths at the package's package.json main / types fields (the compiled output). This is more production-accurate but requires building all dependencies before type-checking consumers:
// packages/app/tsconfig.json — paths pointing to compiled dist
{
"compilerOptions": {
"paths": {
"@myapp/core": ["../core/dist/index.d.ts"],
"@myapp/utils": ["../utils/dist/index.d.ts"]
}
}
}declarationMap — Jump to Source Across Packages
When a consumer package (e.g. app) does "Go to Definition" on a function from utils, without declarationMap the editor jumps to the compiled .d.ts file in dist/ — a wall of type declarations with no implementation. With declarationMap: true, it jumps straight to the original .ts source:
// In the referenced package (utils, core, etc.)
{
"compilerOptions": {
"declaration": true,
"declarationMap": true // enables .d.ts.map files
}
}# With declarationMap: true, the build outputs: packages/utils/dist/ format.js # compiled JavaScript format.d.ts # type declarations format.d.ts.map # source map: .d.ts → original .ts file index.js index.d.ts index.d.ts.map
declarationMap: true on all packages in a monorepo. The only cost is a few small .d.ts.map files in your build output. The benefit — editor "Go to Definition" always landing on real source code — is significant for developer productivity.Incremental Compilation in Practice
Each package with composite: true generates a .tsbuildinfo file. This file stores a fingerprint of every input file, its dependencies, and the outputs produced. On subsequent tsc --build runs, TypeScript compares fingerprints and skips packages where nothing changed:
# After initial build, all packages have .tsbuildinfo files: packages/core/dist/.tsbuildinfo packages/utils/dist/.tsbuildinfo packages/app/dist/.tsbuildinfo # Edit only packages/utils/src/format.ts # tsc --build rebuilds only utils and app (its consumer) # core is untouched — its fingerprint matches $ tsc --build [12:00:01] Project 'packages/core/tsconfig.json' is up to date [12:00:01] Building project 'packages/utils/tsconfig.json'... [12:00:02] Building project 'packages/app/tsconfig.json'... [12:00:02] Build complete.
# Force a complete rebuild (ignores all .tsbuildinfo caches): tsc --build --force # Clean all outputs and .tsbuildinfo files: tsc --build --clean # Type-check only (no emit), still uses incremental cache: tsc --build --noEmit
The prepend Option (Legacy)
The prepend option on a reference tells TypeScript to concatenate the referenced project's output into the current project's output file when using --outFile. It was designed for bundling all packages into a single file:
// packages/app/tsconfig.json
{
"compilerOptions": {
"outFile": "./dist/bundle.js" // single output file
},
"references": [
{ "path": "../core", "prepend": true }, // core output prepended first
{ "path": "../utils", "path": "prepend: true" } // utils output prepended second
]
}prepend option only works when outFile is set and module is AMD or System. Modern bundlers (Vite, webpack, esbuild, Rollup) handle bundling better than TypeScript's outFile. In practice, prepend is rarely used in new projects.Project References with npm Workspaces
Project references pair naturally with npm workspaces (or pnpm/yarn workspaces). The workspace handles node_modules symlinks; TypeScript project references handle type-checking order:
// package.json (root) — npm workspaces
{
"name": "my-monorepo",
"private": true,
"workspaces": [
"packages/*"
],
"scripts": {
"build": "tsc --build",
"build:clean": "tsc --build --clean && tsc --build",
"typecheck": "tsc --build --noEmit",
"dev": "tsc --build --watch"
},
"devDependencies": {
"typescript": "^5.4.0"
}
}# Install all workspace packages and link them npm install # Build all packages in dependency order npm run build # Type-check without emitting (fast CI check) npm run typecheck # Watch mode — rebuilds affected packages on file change npm run dev
Shared tsconfig.base.json
In a monorepo, all packages should share a base config. This avoids duplicating strict settings, target versions, and other common options:
// tsconfig.base.json (root)
{
"compilerOptions": {
"strict": true,
"target": "ES2020",
"module": "CommonJS",
"moduleResolution": "node",
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"declaration": true,
"declarationMap": true,
"sourceMap": true,
"composite": true
}
}// packages/core/tsconfig.json — extends base
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"outDir": "./dist",
"rootDir": "./src"
},
"include": ["src/**/*"]
}// packages/utils/tsconfig.json — extends base
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"outDir": "./dist",
"rootDir": "./src"
},
"include": ["src/**/*"],
"references": [
{ "path": "../core" }
]
}Separating Test and Source Configs
Tests usually need different settings than source code — they run in Node.js (not the browser), they use test framework types, and they should not be included in the library output. A separate tsconfig.test.json handles this:
// packages/utils/tsconfig.test.json
{
"extends": "./tsconfig.json",
"compilerOptions": {
"composite": false, // tests are not referenced by other packages
"rootDir": ".",
"noEmit": true, // tests are run via ts-jest or tsx, not tsc
"types": ["jest", "node"]
},
"include": ["src/**/*", "tests/**/*"],
"references": []
}// jest.config.js or vitest.config.ts
export default {
preset: 'ts-jest',
testEnvironment: 'node',
globals: {
'ts-jest': {
tsconfig: 'tsconfig.test.json' // use test-specific config
}
}
}tsconfig.json needs composite: true. Test configs do not need it because no other package references the test build.Comparison: With vs Without Project References
Scenario | Without References | With References |
|---|---|---|
Build time (large repo) | Full rebuild every time | Only changed packages rebuild |
Type isolation | All types visible to all files | Each package exposes only its public API |
Circular dependency detection | No check | tsc --build errors on cycles |
Build order | Must be managed manually | TypeScript resolves order from references |
Editor "Go to Definition" | May jump to .d.ts in dist | Jumps to original .ts source (with declarationMap) |
CI type-check | Must build all packages | tsc --build --noEmit checks only changed |
Common Mistakes and How to Fix Them
Mistake 1: Adding a referenced package to include.
// Wrong — do not include referenced packages' source directly
{
"include": ["src/**/*", "../core/src/**/*"], // wrong!
"references": [{ "path": "../core" }]
}
// Correct — only include this package's own source
{
"include": ["src/**/*"],
"references": [{ "path": "../core" }]
}Mistake 2: Forgetting "files": [] in the root solution file.
// Wrong — root solution file accidentally picks up files
{
"references": [{ "path": "packages/core" }]
// no "files" or "include" — TypeScript uses defaults and picks up everything
}
// Correct
{
"files": [], // explicitly no files in the solution file
"references": [{ "path": "packages/core" }]
}Mistake 3: Using tsc without --build in a project references setup.
# Wrong — plain tsc ignores the "references" array tsc # Correct — use --build to activate project references tsc --build # Or in watch mode tsc --build --watch
Mistake 4: Missing composite: true on a referenced package.
# Error from tsc --build: error TS6306: Referenced project '../core' must have setting "composite": true. # Fix: add composite: true to packages/core/tsconfig.json
Integrating with Build Tools
TypeScript project references work alongside most build tools. The tool runs tsc --build as part of its pipeline:
// turbo.json (Turborepo) — respects project reference output
{
"pipeline": {
"build": {
"dependsOn": ["^build"],
"outputs": ["dist/**"]
},
"typecheck": {
"dependsOn": ["^build"],
"outputs": []
}
}
}// package.json scripts for Turborepo + project references
{
"scripts": {
"build": "tsc --build",
"typecheck": "tsc --build --noEmit",
"clean": "tsc --build --clean"
}
}.tsbuildinfo incremental cache. Both layers work together — TypeScript skips unchanged files within a package, and the build tool skips unchanged packages entirely.Type-Only Imports Across Packages
When importing types from a referenced package, use import type to make it clear the import is erased at runtime. This also avoids accidental value imports that could cause circular dependency issues:
// packages/app/src/main.ts
// Prefer 'import type' for type-only cross-package imports
import type { User, ApiResponse } from '@myapp/core';
// Use regular import only when you need a runtime value
import { createUserId } from '@myapp/core';
// This is safe and efficient — 'User' and 'ApiResponse' are erased at compile time
function processUser(user: User): ApiResponse<User> {
return {
data: user,
success: true,
timestamp: new Date().toISOString(),
};
}Full Example: Building and Running
# Start from scratch in the monorepo root
npm install
# Build all packages in dependency order (core → utils → app)
tsc --build
# Output structure after build:
packages/
core/dist/
index.js index.d.ts index.d.ts.map .tsbuildinfo
types.js types.d.ts types.d.ts.map
utils/dist/
index.js index.d.ts index.d.ts.map .tsbuildinfo
format.js format.d.ts format.d.ts.map
app/dist/
main.js main.d.ts main.d.ts.map .tsbuildinfo
# Run the app
node packages/app/dist/main.jsAlice Smith <alice@example.com>
January 15, 2024
{
"data": {
"id": "user-001",
"name": "Alice Smith",
"email": "alice@example.com",
"role": "admin",
"createdAt": "2024-01-15T00:00:00.000Z"
},
"success": true,
"message": "User loaded successfully",
"timestamp": "2024-06-01T12:00:00.000Z"
}# Edit packages/utils/src/format.ts — add a new function # Rebuild — only utils and app are rebuilt (core is cached) tsc --build # [12:00:01] Project 'packages/core/tsconfig.json' is up to date # [12:00:01] Building project 'packages/utils/tsconfig.json'... # [12:00:02] Building project 'packages/app/tsconfig.json'... # [12:00:02] Build complete.
composite: true prepares a package to be referenced, how the references array declares dependencies, how tsc --build uses that graph to build in order and skip unchanged packages, and how declarationMap: true preserves "Go to Definition" across package boundaries. Combined with a shared tsconfig.base.json and npm workspaces, project references are the foundation of a fast, well-typed TypeScript monorepo.