TypeScript Project References and Configuration

How TypeScript project references split a monorepo into independently-checkable units — and what composite mode actually does.

March 22, 20265 min read3 / 3

The most important structural change you can make to TypeScript performance in a monorepo isn't a configuration flag — it's project references. They're underused because they require a bit of upfront setup, but the payoff is significant: TypeScript checks each package independently and caches the results.

The Problem Project References Solve

Without project references, TypeScript treats your entire monorepo as a single program. Every time you run tsc, it loads every file from every package into memory and checks it all together.

With project references, each package has its own tsconfig.json. TypeScript checks each package separately, writes the output to a build cache, and on subsequent runs only re-checks packages whose input files have changed.

This is incremental compilation at the package level, not just the file level.

Setting Up tsconfig.base.json

Start with a shared base configuration at the monorepo root. This is the configuration every package will extend:

JSON
// tsconfig.base.json { "compilerOptions": { "target": "ES2022", "module": "ESNext", "moduleResolution": "bundler", "strict": true, "skipLibCheck": true, "declaration": true, "declarationMap": true, "incremental": true } }

A few things worth noting here:

  • declaration: true — each package generates .d.ts files. Other packages reference these declaration files rather than the source.
  • declarationMap: true — maps the declaration files back to source for go-to-definition in editors.
  • incremental: true — enables file-level caching within each package. Combined with project references, this gives you two levels of caching.

Making a Package composite

For a package to participate in project references, it needs composite: true in its tsconfig.json:

JSON
// packages/ui/tsconfig.json { "extends": "../../tsconfig.base.json", "compilerOptions": { "composite": true, "outDir": "./dist", "rootDir": "./src" }, "include": ["src/**/*.ts", "src/**/*.tsx"] }

composite: true enforces several constraints that make caching reliable:

  • rootDir must be specified (or inferrable)
  • declaration: true is implied
  • Every output file must correspond to a specific input file
  • TypeScript writes a .tsbuildinfo file alongside the output — this is the build cache

The .tsbuildinfo file is what makes re-builds fast. On the next run, TypeScript compares file hashes against the stored state in .tsbuildinfo and skips files that haven't changed.

Declaring References

The consuming package declares which packages it depends on:

JSON
// apps/dashboard/tsconfig.json { "extends": "../../tsconfig.base.json", "compilerOptions": { "composite": true, "outDir": "./dist", "rootDir": "./src" }, "include": ["src/**/*.ts", "src/**/*.tsx"], "references": [ { "path": "../../packages/ui" }, { "path": "../../packages/utils" } ] }

The references array tells TypeScript: when you check apps/dashboard, also check these packages if they're not already built.

At the root level, you can have a tsconfig.json that references all packages without actually including any source files itself — purely an orchestration point:

JSON
// tsconfig.json (root) { "files": [], "references": [ { "path": "packages/ui" }, { "path": "packages/utils" }, { "path": "apps/dashboard" }, { "path": "apps/shell" } ] }

Running tsc --build (or tsc -b) from the root then checks all referenced packages in dependency order.

The Build Command

Project references use a different command:

Bash
# Check all projects in dependency order tsc --build # Check a specific project tsc --build packages/ui # Clean the build output tsc --build --clean # Force a full rebuild (ignore cache) tsc --build --force # Watch mode with project references tsc --build --watch

tsc --build is smarter than tsc alone: it respects the dependency graph, builds packages in the correct order, and skips packages whose .tsbuildinfo shows nothing has changed.

What Consuming Code Looks Like

With project references in place, the consuming package imports from the package's declaration files, not its source:

TypeScript
// Before project references — TypeScript reads source files import { Button } from '../../packages/ui/src/Button'; // After — TypeScript reads declaration files from dist import { Button } from '@myapp/ui';

The package.json for each package needs to point types (or exports["."].types) at the generated declaration files:

JSON
// packages/ui/package.json { "name": "@myapp/ui", "main": "./dist/index.js", "types": "./dist/index.d.ts", "exports": { ".": { "types": "./dist/index.d.ts", "import": "./dist/index.js" } } }

Now when apps/dashboard imports @myapp/ui, TypeScript reads the pre-compiled declaration file rather than traversing and type-checking all of the source code in the package. The type-checking work for @myapp/ui happened when that package was built.

Fitting This Into a Turborepo Workflow

Project references and Turborepo are complementary. Turborepo caches task outputs (build artifacts, test results). TypeScript project references cache type-checking results at the file level within each package.

The Turborepo turbo.json for a typecheck task looks like:

JSON
{ "tasks": { "typecheck": { "dependsOn": ["^typecheck"], "outputs": ["dist/**", "*.tsbuildinfo"] } } }

Including *.tsbuildinfo in outputs means Turborepo can restore the TypeScript build cache from its own remote cache — so a fresh CI machine doesn't have to re-type-check packages that haven't changed since the last run.

When to Reach for This

Project references are worth setting up when:

  • You're in a monorepo with multiple packages
  • Type checking is slow enough that developers are skipping it locally
  • CI type check time is above a threshold you care about (roughly: over two minutes means this will help)

The setup cost is real — you need to wire up every package, fix any circular type dependencies the constraints expose, and keep the .tsbuildinfo files in the right place. But once it's running, the per-package cache means that touching one package doesn't invalidate the type-check results for packages that didn't change.

For a monorepo with 20+ packages, this is not optional. It's what makes TypeScript at scale actually work.

Enjoyed this? Get more like it.

Deep dives on system design, React, web development, and personal finance — straight to your inbox. Free, always.