Difficulty: Advanced
TypeScript project references allow you to structure a large codebase as multiple smaller TypeScript projects that reference each other. Each sub-project has its own tsconfig.json with `composite: true`, and the root project lists them in the `references` array. This enables incremental builds - TypeScript only recompiles projects whose source files have changed, dramatically improving build times in large monorepos.
The `composite: true` flag is required in every referenced project's tsconfig.json. It enforces two rules: `declaration` must be true (so other projects can consume the types), and all source files must be matched by the `include` patterns. This ensures that TypeScript can reliably determine the project's public API from its declaration files without needing to re-parse source code.
To build a project with references, you use `tsc --build` (or `tsc -b`) instead of plain `tsc`. Build mode understands the dependency graph between projects and builds them in the correct order. It checks file timestamps and only rebuilds projects that are out of date. The `--clean` flag removes all build outputs, and `--force` rebuilds everything regardless of timestamps.
Declaration maps (`declarationMap: true`) are a companion feature that creates .d.ts.map files alongside your declarations. These maps allow your IDE to navigate from a type declaration directly to its source implementation in the referenced project, rather than landing on the .d.ts file. This is essential for a smooth development experience in multi-project setups, enabling features like Go to Definition and Rename across project boundaries.
// Monorepo structure with project references:
// packages/
// shared/
// tsconfig.json { composite: true, declaration: true }
// src/types.ts
// server/
// tsconfig.json { references: [{ path: "../shared" }] }
// src/index.ts (imports from shared)
// client/
// tsconfig.json { references: [{ path: "../shared" }] }
// src/App.tsx (imports from shared)
// tsconfig.json { references: [...all packages] }
// Simulating the project structure
interface ProjectConfig {
name: string;
composite: boolean;
references: string[];
files: string[];
}
const projects: ProjectConfig[] = [
{ name: 'shared', composite: true, references: [], files: ['types.ts', 'utils.ts'] },
{ name: 'server', composite: true, references: ['shared'], files: ['index.ts', 'routes.ts'] },
{ name: 'client', composite: true, references: ['shared'], files: ['App.tsx', 'main.tsx'] }
];
function buildOrder(projects: ProjectConfig[]): string[] {
const built = new Set<string>();
const order: string[] = [];
function build(name: string): void {
if (built.has(name)) return;
const project = projects.find(p => p.name === name)!;
project.references.forEach(ref => build(ref));
built.add(name);
order.push(name);
}
projects.forEach(p => build(p.name));
return order;
}
console.log(`Build order: ${buildOrder(projects).join(' -> ')}`);
projects.forEach(p => {
console.log(`${p.name}: ${p.files.length} files, refs: [${p.references.join(', ') || 'none'}]`);
});
tsc --build resolves the dependency graph and builds projects in topological order. Shared dependencies are built first, then consumers.
// A composite project's tsconfig.json:
// {
// "compilerOptions": {
// "composite": true,
// "declaration": true,
// "declarationMap": true,
// "outDir": "./dist",
// "rootDir": "./src"
// },
// "include": ["src//*"]
// }
// Checking composite requirements
interface CompositeConfig {
composite: boolean;
declaration: boolean;
declarationMap: boolean;
outDir: string;
include: string[];
}
function validateComposite(config: CompositeConfig): string[] {
const errors: string[] = [];
if (config.composite && !config.declaration) {
errors.push('composite requires declaration: true');
}
if (config.composite && config.include.length === 0) {
errors.push('composite requires include patterns');
}
if (config.composite && !config.outDir) {
errors.push('composite projects should have outDir');
}
if (errors.length === 0) {
errors.push('Configuration is valid');
}
return errors;
}
const validConfig: CompositeConfig = {
composite: true, declaration: true, declarationMap: true,
outDir: './dist', include: ['src//*']
};
const invalidConfig: CompositeConfig = {
composite: true, declaration: false, declarationMap: false,
outDir: '', include: []
};
console.log('Valid config:');
validateComposite(validConfig).forEach(m => console.log(` ${m}`));
console.log('Invalid config:');
validateComposite(invalidConfig).forEach(m => console.log(` ${m}`));
Composite projects must have declaration: true and include patterns. These constraints ensure reliable incremental builds.
// Simulating incremental build behavior
interface BuildState {
project: string;
lastBuildTime: number;
lastModifiedTime: number;
}
function needsRebuild(state: BuildState): boolean {
return state.lastModifiedTime > state.lastBuildTime;
}
function incrementalBuild(states: BuildState[]): string[] {
const rebuilt: string[] = [];
for (const state of states) {
if (needsRebuild(state)) {
rebuilt.push(state.project);
state.lastBuildTime = Date.now();
}
}
return rebuilt;
}
const now = Date.now();
const states: BuildState[] = [
{ project: 'shared', lastBuildTime: now - 1000, lastModifiedTime: now - 500 },
{ project: 'server', lastBuildTime: now - 1000, lastModifiedTime: now - 2000 },
{ project: 'client', lastBuildTime: now - 1000, lastModifiedTime: now - 100 }
];
const rebuilt = incrementalBuild(states);
console.log(`Projects rebuilt: ${rebuilt.join(', ')}`);
console.log(`Projects skipped: ${states.filter(s => !rebuilt.includes(s.project)).map(s => s.project).join(', ')}`);
console.log(`Total: ${rebuilt.length} rebuilt, ${states.length - rebuilt.length} skipped`);
Incremental builds compare file modification times with the last build time. Only projects with changes since the last build are recompiled.
composite projects, build mode, project references, incremental builds, tsc --build, declaration maps