TypeScriptProject References & Monorepos

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)

JSON
// 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/**/*"]
}
Note
Setting 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:

JSON
// 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
  ]
}

JSON
// 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
  ]
}

JSON
// 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:

Bash
# 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.
Tip
In a monorepo, add a build script to 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:

Bash
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:

JSON
// 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" }
  ]
}
Warning
Set "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

JSON
// 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/**/*"]
}

TS
// 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;
}

TS
// packages/core/src/index.ts
export type { User, ApiResponse, UserId } from './types';
export { createUserId } from './types';

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

JSON
// 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" }
  ]
}

TS
// 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',
  });
}

TS
// packages/utils/src/index.ts
export { formatUserName, formatApiResponse, formatDate } from './format';

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

JSON
// 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" }
  ]
}

TS
// 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:

JSON
// 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" }
  ]
}
Note
Using 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:

JSON
// 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:

JSON
// In the referenced package (utils, core, etc.)
{
  "compilerOptions": {
    "declaration": true,
    "declarationMap": true   // enables .d.ts.map files
  }
}

Bash
# 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
Tip
Always enable 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:

Bash
# 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.

Bash
# 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:

JSON
// 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
  ]
}
Warning
The 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:

JSON
// 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"
  }
}

Bash
# 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:

JSON
// 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
  }
}

JSON
// packages/core/tsconfig.json — extends base
{
  "extends": "../../tsconfig.base.json",
  "compilerOptions": {
    "outDir": "./dist",
    "rootDir": "./src"
  },
  "include": ["src/**/*"]
}

JSON
// 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:

JSON
// 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": []
}

JSON
// 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
    }
  }
}
Note
Only the source 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.

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

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

Bash
# 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.

Bash
# 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:

JSON
// turbo.json (Turborepo) — respects project reference output
{
  "pipeline": {
    "build": {
      "dependsOn": ["^build"],
      "outputs": ["dist/**"]
    },
    "typecheck": {
      "dependsOn": ["^build"],
      "outputs": []
    }
  }
}

JSON
// package.json scripts for Turborepo + project references
{
  "scripts": {
    "build": "tsc --build",
    "typecheck": "tsc --build --noEmit",
    "clean": "tsc --build --clean"
  }
}
Tip
Turborepo and Nx use their own caching on top of TypeScript's .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:

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

Bash
# 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.js
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"
}

Bash
# 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.
Success
You now understand TypeScript project references end-to-end: how 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.