πŸš€AdvancedAdvanced Integration

Monorepos & TypeScript Project References

Structure large codebases with TypeScript project references for incremental builds, proper cross-package type checking, and monorepo tooling integration.

Overview

As TypeScript codebases grow, a single `tsconfig.json` becomes a bottleneck: every file must be checked and compiled together, build times grow linearly, and there is no clear boundary between packages. **TypeScript project references** (introduced in TS 3.0) solve this by composing multiple `tsconfig.json` files into a dependency graph. Each sub-project compiles independently and caches its output. When you change a file in package A, only A and packages that reference A are recompiled. In a monorepo (a single repository containing multiple packages), project references provide: - **Incremental compilation** - only recompile what changed - **Type-safe cross-package imports** - TypeScript checks across package boundaries - **Build ordering** - TypeScript knows which packages to compile first - **IDE performance** - editors navigate to source instead of compiled `.d.ts` files Popular monorepo tools (Turborepo, Nx, Lerna) integrate with TypeScript project references. Understanding how they work helps you debug build issues and optimize CI times.

Key Points

  • β€’Each package gets its own `tsconfig.json` with `"composite": true`
  • β€’`"composite": true` requires explicit `rootDir`, emits `.d.ts` files, and enables `--build` caching
  • β€’`references: [{ path: "../other-package" }]` declares a dependency on another tsconfig
  • β€’`tsc --build` (or `tsc -b`) builds in dependency order; `--watch` for incremental watch mode
  • β€’`paths` in `tsconfig.json` maps package names to source - avoids needing to `npm link`
  • β€’Root `tsconfig.json` typically has no `include` - only `references` to all sub-projects
  • β€’Turborepo and Nx use project references to determine what to cache and rebuild

Examples

Basic monorepo tsconfig structure

// Monorepo structure:
// /
// β”œβ”€β”€ packages/
// β”‚   β”œβ”€β”€ core/
// β”‚   β”‚   β”œβ”€β”€ src/index.ts
// β”‚   β”‚   └── tsconfig.json     <-- composite sub-project
// β”‚   β”œβ”€β”€ ui/
// β”‚   β”‚   β”œβ”€β”€ src/index.ts
// β”‚   β”‚   └── tsconfig.json     <-- composite sub-project (depends on core)
// β”‚   └── app/
// β”‚       β”œβ”€β”€ src/index.ts
// β”‚       └── tsconfig.json     <-- composite sub-project (depends on ui, core)
// β”œβ”€β”€ tsconfig.json             <-- root (no src, just references)
// └── package.json

// packages/core/tsconfig.json
{
  "compilerOptions": {
    "composite": true,          // Enables --build mode caching
    "rootDir": "./src",
    "outDir": "./dist",
    "declaration": true,        // Required by composite
    "declarationMap": true,     // Jump-to-source works in editors
    "strict": true
  },
  "include": ["src/**/*"]
}

// packages/ui/tsconfig.json
{
  "compilerOptions": {
    "composite": true,
    "rootDir": "./src",
    "outDir": "./dist",
    "declaration": true,
    "declarationMap": true,
    "strict": true
  },
  "references": [
    { "path": "../core" }       // Depends on core
  ],
  "include": ["src/**/*"]
}

// Root tsconfig.json - the build entry point
{
  "files": [],                  // No files of its own
  "references": [
    { "path": "./packages/core" },
    { "path": "./packages/ui" },
    { "path": "./packages/app" }
  ]
}

Path mapping for cross-package imports without building first

// packages/ui/tsconfig.json - with path aliases
{
  "compilerOptions": {
    "composite": true,
    "rootDir": "./src",
    "outDir": "./dist",
    "declaration": true,
    "strict": true,
    "baseUrl": ".",
    "paths": {
      "@myapp/core": ["../core/src/index.ts"]   // Map to source directly
    }
  },
  "references": [
    { "path": "../core" }
  ]
}

// packages/ui/src/Button.ts
import { formatText } from '@myapp/core';  // Works! Resolves to core/src/index.ts
// In editor: Go-to-definition jumps to source, not .d.ts
// During build: TypeScript uses the compiled output from core/dist

export function Button(label: string): string {
  return `<button>${formatText(label)}</button>`;
}

// packages/core/src/index.ts
export function formatText(text: string): string {
  return text.trim().toLowerCase();
}

// Build command at root
// tsc --build           Build all packages in dependency order
// tsc --build --watch   Watch mode - incremental on file change
// tsc --build --clean   Delete all compiled output

Turborepo integration with TypeScript references

// turbo.json - define the build pipeline
{
  "pipeline": {
    "build": {
      "dependsOn": ["^build"],   // Build dependencies first
      "outputs": ["dist/**"],    // Cache these files
      "inputs": ["src/**/*.ts", "tsconfig.json"]
    },
    "type-check": {
      "dependsOn": ["^type-check"],
      "outputs": []              // No output - pure check
    }
  }
}

// packages/core/package.json
{
  "name": "@myapp/core",
  "scripts": {
    "build": "tsc --build",
    "type-check": "tsc --noEmit"
  },
  "exports": {
    ".": {
      "types": "./dist/index.d.ts",
      "import": "./dist/index.js"
    }
  }
}

// packages/ui/package.json
{
  "name": "@myapp/ui",
  "dependencies": {
    "@myapp/core": "*"           // Local workspace reference
  },
  "scripts": {
    "build": "tsc --build",
    "type-check": "tsc --noEmit"
  }
}

// Root package.json (npm/yarn/pnpm workspaces)
{
  "name": "myapp-monorepo",
  "private": true,
  "workspaces": ["packages/*"],
  "scripts": {
    "build": "turbo run build",
    "type-check": "tsc --build --noEmit"
  }
}

// CI: only re-runs what changed since last commit
// npx turbo run build --filter=[HEAD^1]  (changed packages only)

Common Mistakes

  • Forgetting `"composite": true` in sub-project tsconfigs: without it, `tsc --build` cannot cache outputs and will not understand the reference graph
  • Importing from a package's `src/` directory directly in `package.json` dependencies instead of using path aliases: this bypasses the type-checker's dependency tracking and causes stale type information
  • Not setting `declarationMap: true`: without declaration maps, Go-to-definition in editors jumps to the compiled `.d.ts` file instead of the TypeScript source, making debugging much harder
  • Circular references between packages: TypeScript project references do not allow cycles. If package A references B and B references A, the build will fail with a circular dependency error

Note

Start with `tsc --build --verbose` to see exactly what TypeScript is and is not recompiling. The `--force` flag skips the cache (useful when debugging stale type errors). For large monorepos, storing the `.tsbuildinfo` cache files in CI is the single highest-impact optimization for build time.

Exercises

Write TypeScript code to solve each exercise. Click Run to compile and execute it right here, then mark as done.

Loading editor…

Completed: 0 / 2 exercises

Knowledge Check

Question 1 of 4

What does `"composite": true` in a `tsconfig.json` enable?