In monorepos encompassing dozens of internal packages and applications, a naive TypeScript configuration utilizing a single root tsconfig.json leads to an architectural dead end:
ts.Program).JavaScript heap out of memory).To horizontally scale type checking in large codebases, TypeScript provides Project References.
Project References relies on three core principles:
"composite": true, enforcing clean module boundaries and mandatory type declaration emission.tsc --build (tsc -b): The compiler builds a directed acyclic graph (DAG) of project dependencies and compiles packages in topological order, skipping up-to-date modules..tsbuildinfo: If changes to a package's internal implementation do not mutate its public API (its emitted .d.ts remains identical), TypeScript avoids recompiling dependent upstream packages.Under a Project References architecture, each package compiles in isolation, providing upstream dependents with compact, emitted type contracts (.d.ts) rather than unparsed source trees:
+-------------------------------------------------------------------------+
| TOPOLOGICAL MONOREPO BUILD GRAPH (DAG) |
+-------------------------------------------------------------------------+
| ROOT tsconfig.json |
| "references": [ { "path": "apps/web" }, { "path": "packages/ui" } ] |
+-------------------------------------------------------------------------+
|
v
+-------------------------------------------------------------------------+
| apps/web/tsconfig.json |
| "composite": true |
| "references": [ { "path": "../../packages/ui" } ] |
| (Reads ONLY emitted .d.ts from packages/ui/dist) |
+-------------------------------------------------------------------------+
|
v
+-------------------------------------------------------------------------+
| packages/ui/tsconfig.json |
| "composite": true |
| "references": [ { "path": "../core" } ] |
| (Emits dist/index.d.ts and writes .tsbuildinfo cache) |
+-------------------------------------------------------------------------+
|
v
+-------------------------------------------------------------------------+
| packages/core/tsconfig.json (Base Leaf Node) |
| "composite": true |
| "references": [] |
+-------------------------------------------------------------------------+When a developer edits the internal implementation of a private function in packages/core without mutating its exported signature:
tsc -b compiles packages/core.packages/core/dist/index.d.ts file remains bit-for-bit identical.packages/core/.tsbuildinfo and skips type checking for packages/ui and apps/web.tsc --build shrinks full CI type verification from 15 minutes down to 90 seconds by reusing remote .tsbuildinfo caches.@company/ui package instantly serves emitted .d.ts files to all consuming applications without requiring heavy background watcher builds for every dependency.composite: true Architectural Flag#Enabling composite: true informs the compiler that the project participates in a larger dependency graph.
Mandatory requirements for composite projects:
declaration: true: The project must emit declaration files because dependent subprojects consume them.rootDir: Must be explicitly set (typically ./src). Importing files outside this boundary without a project reference is strictly forbidden.include or explicitly enumerated in files.{
"extends": "../../packages/tsconfig/base.json",
"compilerOptions": {
"composite": true,
"outDir": "./dist",
"rootDir": "./src",
"tsBuildInfoFile": "./dist/.tsbuildinfo",
"declaration": true,
"declarationMap": true
},
"include": ["src/**/*"]
}A dependent package declares links to its upstream dependencies in its references array:
{
"extends": "../../packages/tsconfig/base.json",
"compilerOptions": {
"composite": true,
"outDir": "./dist",
"rootDir": "./src",
"tsBuildInfoFile": "./dist/.tsbuildinfo",
"declaration": true,
"declarationMap": true
},
"references": [
{ "path": "../core" }
],
"include": ["src/**/*"]
}At the monorepo root, a coordinator tsconfig.json without source files (files: []) aggregates all graph nodes:
{
"files": [],
"references": [
{ "path": "./packages/core" },
{ "path": "./packages/ui" },
{ "path": "./apps/web" },
{ "path": "./apps/docs" }
]
}Executing incremental multi-package compilation across the entire monorepo requires a single command:
pnpm exec tsc --build --verboseThe compiler computes the topological execution order and outputs the build trace:
[14:20:01] Projects in this build:
* packages/core/tsconfig.json
* packages/ui/tsconfig.json
* apps/web/tsconfig.json
* tsconfig.json
[14:20:02] Project 'packages/core' is up to date because newest input 'src/index.ts' is older than output 'dist/index.d.ts'
[14:20:02] Building project 'packages/ui/tsconfig.json'...
[14:20:04] Updating output timestamps of project 'apps/web/tsconfig.json'...Reading references blocks across subprojects and constructing an acyclic dependency graph.
Comparing source checksums and .d.ts hashes against cached .tsbuildinfo manifests.
Compiling stale subprojects in topological order and writing fresh .d.ts and sourcemaps.
If emitted .d.ts signatures did not change, downstream compilation is bypassed instantly.
A recurring problem in enterprise monorepos is synchronization drift: a developer adds an internal dependency to package.json but forgets to add the project reference in tsconfig.json. Below is a production script to audit and auto-repair references:
import fs from 'node:fs';
import path from 'node:path';
import { globSync } from 'glob';
console.log('🔍 Checking Project References consistency across monorepo...');
// Discover all packages and applications
const packageJsonFiles = globSync('{packages,apps}/*/package.json');
const packageMap = new Map();
for (const pkgPath of packageJsonFiles) {
const content = JSON.parse(fs.readFileSync(pkgPath, 'utf8'));
const dir = path.dirname(pkgPath);
packageMap.set(content.name, dir);
}
let hasDiscrepancy = false;
for (const [pkgName, pkgDir] of packageMap.entries()) {
const tsconfigPath = path.join(pkgDir, 'tsconfig.json');
const pkgJsonPath = path.join(pkgDir, 'package.json');
if (!fs.existsSync(tsconfigPath)) continue;
const pkgJson = JSON.parse(fs.readFileSync(pkgJsonPath, 'utf8'));
const tsconfig = JSON.parse(fs.readFileSync(tsconfigPath, 'utf8'));
const internalDeps = [];
const allDeps = {
...pkgJson.dependencies,
...pkgJson.devDependencies,
};
for (const depName of Object.keys(allDeps)) {
if (packageMap.has(depName)) {
const targetDir = packageMap.get(depName);
const relativePath = path.relative(pkgDir, targetDir).replace(/\\/g, '/');
internalDeps.push(relativePath);
}
}
const currentRefs = (tsconfig.references || []).map((ref) =>
ref.path.replace(/\\/g, '/')
);
const missingRefs = internalDeps.filter((dep) => !currentRefs.includes(dep));
if (missingRefs.length > 0) {
console.warn(`⚠️ Package ${pkgName} is missing tsconfig references:`, missingRefs);
hasDiscrepancy = true;
// Automatically synchronize
tsconfig.references = [
...(tsconfig.references || []),
...missingRefs.map((refPath) => ({ path: refPath })),
];
fs.writeFileSync(tsconfigPath, JSON.stringify(tsconfig, null, 2) + '\n');
console.log(`✅ Synchronized missing references in ${tsconfigPath}`);
}
}
if (hasDiscrepancy) {
console.log('🔄 All tsconfig.json references successfully synchronized.');
} else {
console.log('✨ All Project References are perfectly aligned with package.json dependencies.');
}Configuring "paths": { "@repo/ui": ["../../packages/ui/src"] } forces the compiler to inline another package's source code into the current application's program. The dependency gets recompiled for every application importing it, destroying incrementality and causing huge memory spikes.
// ❌ Anti-pattern: Mapping paths directly into foreign source trees
{
"compilerOptions": {
"paths": {
"@repo/ui": ["../../packages/ui/src/index.ts"]
}
}
}
// ✅ Recommended practice: Project references with imports resolved from compiled .d.ts
{
"references": [
{ "path": "../../packages/ui" }
]
}If project A references project B, and project B directly or transitively references project A, tsc --build aborts with Project references may not form a circular graph.
Remedy: Extract shared interfaces and contracts into a leaf package (e.g., packages/contracts or packages/types) positioned at the base of the dependency tree.
Deleting dist output while leaving .tsbuildinfo intact causes tsc -b to assume the project is still up to date based on the cached build record, failing to emit declarations and throwing Cannot find module errors for consumers.
Remedy: Always clean builds using the native command: tsc --build --clean.
Enable 'composite': true, 'declaration': true, and configure outDir in packages/* tsconfig files.
Purge relative paths pointing to foreign src directories from the paths configuration.
Add references arrays with relative paths to upstream package directories.
Configure the root tsconfig.json with files: [] and list all workspace packages under references.
Execute pnpm exec tsc --build --verbose to validate the topological build DAG.