Project References

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.

Code examples

Project References Structure

// 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.

Composite Project Config

// 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.

Incremental Build Simulation

// 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.

Key points

Concepts covered

composite projects, build mode, project references, incremental builds, tsc --build, declaration maps