cheat sheet

TypeScript Project References

Split a TypeScript codebase into composite sub-projects, build them incrementally in dependency order with tsc --build, and integrate the result with bundlers, Turborepo, and Nx.

TypeScript Project References — Composite Builds & Monorepos

What it is

Project references are TypeScript's built-in answer to "my project is too big for a single tsc invocation". By marking each sub-project with composite: true and listing its dependencies in a top-level references array, you turn a monolithic compile into a graph of smaller, independently-cacheable builds that tsc --build walks in dependency order. The compiler writes a .tsbuildinfo file per project recording inputs and signatures so unchanged projects skip work entirely on the next build — typically a 5-50× speedup on monorepo CI. Reach for project references when you have multiple publishable packages, want gradual TypeScript migration of a JS codebase, or need clean separation between client/server/shared code. The alternatives — bundler-driven builds (Vite, esbuild, Bun) or task-runner caching (Turborepo, Nx) — coexist with project references rather than replace them: bundlers handle bundling, project references handle type-checking and .d.ts emit.

Install

Project references are a built-in feature of tsc. No extra package is required — only TypeScript itself.

bash
# In every package of a monorepo
npm install -D typescript

# Or once at the workspace root
npm install -D typescript -w

Output: (none — exits 0 on success)

Verify that tsc --build is available — it's just an alias for tsc -b.

bash
npx tsc --build --help | head -3

Output:

text
tsc: The TypeScript Compiler - Version 5.4.5
                                                                       TS

COMMON COMMANDS

Syntax

The two moving parts are (a) composite: true in each leaf tsconfig.json, and (b) a references array — either at the top-level solution file, or in any tsconfig that depends on another project.

bash
tsc --build [project...] [--watch] [--clean] [--force] [--dry] [--verbose]

Output: (none — exits 0 on success)

Essential flags

FlagMeaning
-b, --buildTreat inputs as projects to build (rather than files to compile).
--watch, -wWatch all referenced projects and rebuild on change.
--cleanDelete every outDir, .d.ts, and .tsbuildinfo for the project graph.
--forceRebuild every project even if .tsbuildinfo says it is up to date.
--dryPrint what would be built without actually building.
--verboseExplain why each project is or isn't being rebuilt.
--incrementalEnable .tsbuildinfo cache (implied by composite).
--listFilesPrint every file included in the compilation.

Composite projects

A "composite" project is a tsconfig with composite: true in compilerOptions. The flag silently enables three other options — incremental, declaration, and declarationMap — and adds two constraints: rootDir defaults to the directory containing the tsconfig, and every input file must be listed in files or matched by include. The compiler can then emit a .d.ts for every source file and trust that consumers see a stable public API.

jsonc
// packages/shared/tsconfig.json
{
  "compilerOptions": {
    "composite": true,
    "target": "ES2022",
    "module": "NodeNext",
    "moduleResolution": "NodeNext",
    "rootDir": "src",
    "outDir": "dist",
    "declaration": true,
    "declarationMap": true,
    "sourceMap": true,
    "strict": true,
    "skipLibCheck": true
  },
  "include": ["src/**/*"]
}

A project flagged composite produces dist/*.js, dist/*.d.ts, dist/*.d.ts.map, dist/*.js.map, and dist/.tsbuildinfo on first build. The .d.ts files are what other referencing projects type-check against — never the original .ts source.

bash
npx tsc --build packages/shared --verbose

Output:

text
[12:00:00] Projects in this build:
    * packages/shared/tsconfig.json

[12:00:00] Project 'packages/shared/tsconfig.json' is out of date because output file 'packages/shared/dist/.tsbuildinfo' does not exist

[12:00:00] Building project '/repo/packages/shared/tsconfig.json'...

[12:00:02] Done.

The references array

references is an array of { "path": "..." } objects pointing at other tsconfigs (or at directories containing a tsconfig.json). Each entry tells tsc --build "before compiling this project, make sure the referenced project is up to date, and resolve imports against its outDir (.d.ts) — not its source".

jsonc
// packages/api/tsconfig.json
{
  "compilerOptions": {
    "composite": true,
    "target": "ES2022",
    "module": "NodeNext",
    "moduleResolution": "NodeNext",
    "rootDir": "src",
    "outDir": "dist",
    "declaration": true,
    "declarationMap": true,
    "strict": true,
    "skipLibCheck": true
  },
  "references": [
    { "path": "../shared" }
  ],
  "include": ["src/**/*"]
}

In packages/api/src/index.ts, importing @repo/shared now resolves to packages/shared/dist/index.d.ts for type-checking and packages/shared/dist/index.js at runtime — exactly what npm consumers will see.

typescript
// packages/api/src/index.ts
import { greet } from "@repo/shared";

export function hello(name: string): string {
  return greet(name).toUpperCase();
}

The runtime resolution of @repo/shared still goes through your package manager (npm/pnpm/yarn workspaces) or a paths mapping — references is purely a type-checker concern.

Solution-style root tsconfig

A "solution" tsconfig has no compilerOptions of its own, only files: [] and a references array listing every project. It's the entry point for tsc --build . at the repository root and is the closest equivalent to a Visual Studio solution file.

jsonc
// tsconfig.json (repository root)
{
  "files": [],
  "references": [
    { "path": "./packages/shared" },
    { "path": "./packages/api" },
    { "path": "./packages/ui" },
    { "path": "./packages/app" }
  ]
}

Building the whole graph in one command:

bash
npx tsc --build

Output:

text
(no output — exit code 0 when all projects type-check cleanly)

Add --verbose to see the dependency order TypeScript computed:

bash
npx tsc --build --verbose

Output:

text
[12:00:00] Projects in this build:
    * packages/shared/tsconfig.json
    * packages/api/tsconfig.json
    * packages/ui/tsconfig.json
    * packages/app/tsconfig.json
    * tsconfig.json

[12:00:00] Project 'packages/shared/tsconfig.json' is up to date because newest input is older than output
[12:00:00] Project 'packages/api/tsconfig.json' is up to date because newest input is older than output
[12:00:00] Project 'packages/ui/tsconfig.json' is up to date because newest input is older than output
[12:00:00] Project 'packages/app/tsconfig.json' is up to date because newest input is older than output

Incremental builds and .tsbuildinfo

Every composite project emits a .tsbuildinfo JSON file (default location: alongside outDir) containing a content hash of every input file, the list of emitted outputs, and a signature for each .d.ts. On the next tsc --build, the compiler reads this file and skips any project whose inputs are unchanged and whose dependencies' .d.ts signatures haven't changed.

bash
# First build — everything compiles
npx tsc --build --verbose | grep -E "Building|up to date"

Output:

text
[12:00:00] Building project '/repo/packages/shared/tsconfig.json'...
[12:00:02] Building project '/repo/packages/api/tsconfig.json'...
[12:00:03] Building project '/repo/packages/ui/tsconfig.json'...
[12:00:04] Building project '/repo/packages/app/tsconfig.json'...

Touch a single file in shared, then re-build:

bash
echo "// touch" >> packages/shared/src/index.ts
npx tsc --build --verbose | grep -E "Building|up to date"

Output:

text
[12:00:10] Project 'packages/shared/tsconfig.json' is out of date because output 'packages/shared/dist/.tsbuildinfo' is older than input 'packages/shared/src/index.ts'
[12:00:10] Building project '/repo/packages/shared/tsconfig.json'...
[12:00:12] Project 'packages/api/tsconfig.json' is up to date with .d.ts files from its dependencies
[12:00:12] Project 'packages/ui/tsconfig.json' is up to date with .d.ts files from its dependencies
[12:00:12] Project 'packages/app/tsconfig.json' is up to date with .d.ts files from its dependencies

Only shared rebuilt — the change to a comment didn't alter the .d.ts signature, so downstream projects stayed cached. This is the killer feature of composite builds.

You can relocate .tsbuildinfo with tsBuildInfoFile:

jsonc
{
  "compilerOptions": {
    "composite": true,
    "tsBuildInfoFile": "./.cache/tsbuildinfo"
  }
}

Watch mode for monorepos

tsc --build --watch watches every project in the graph and rebuilds the affected subset on change. It uses the same .tsbuildinfo cache as one-off builds, so the first iteration is fast even after a git pull.

bash
npx tsc --build --watch

Output:

text
[12:00:00] Starting compilation in watch mode...
[12:00:01] Found 0 errors. Watching for file changes.

When a file changes in shared, the cascade is:

text
[12:01:15] File change detected. Starting incremental compilation...
[12:01:15] Building project '/repo/packages/shared/tsconfig.json'...
[12:01:16] Building project '/repo/packages/api/tsconfig.json'...
[12:01:17] Found 0 errors. Watching for file changes.

For multi-process speed in CI, pair tsc --build with a parallel runner — tsc itself is single-threaded, but Turborepo or Nx can run several tsc -b invocations side by side on independent sub-graphs.

Workspace example: app + ui + shared

A common shape: a Vite-built app consumes a ui component library and a shared utility package. Each package has its own tsconfig.json, plus the workspace root has a solution tsconfig.

text
.
|-- package.json            (workspaces: ["packages/*"])
|-- tsconfig.json           (solution-style)
|-- packages
    |-- shared
    |   |-- package.json    (name: @repo/shared, exports ./dist)
    |   |-- tsconfig.json   (composite, no references)
    |   |-- src/index.ts
    |-- ui
    |   |-- package.json    (name: @repo/ui)
    |   |-- tsconfig.json   (composite, references shared)
    |   |-- src/Button.tsx
    |-- app
        |-- package.json    (name: @repo/app)
        |-- tsconfig.json   (noEmit, references shared & ui)
        |-- src/main.tsx

packages/app/tsconfig.json uses noEmit: true because Vite handles JS emit — tsc is only run for type-checking in CI. App-level projects can mix noEmit with references as long as the referenced projects are composite.

jsonc
{
  "compilerOptions": {
    "target": "ES2022",
    "module": "ESNext",
    "moduleResolution": "bundler",
    "lib": ["ES2022", "DOM", "DOM.Iterable"],
    "jsx": "react-jsx",
    "strict": true,
    "skipLibCheck": true,
    "noEmit": true
  },
  "references": [
    { "path": "../shared" },
    { "path": "../ui" }
  ],
  "include": ["src"]
}

Build everything from the root, then start the dev server:

bash
npx tsc --build && npx vite --config packages/app/vite.config.ts

Output:

text
  VITE v5.4.0  ready in 312 ms

  ->  Local:   http://localhost:5173/
  ->  press h + enter to show help

Clean and force rebuilds

--clean deletes everything that tsc --build emitted, walking the reference graph. It's the safe way to nuke caches without rm-ing random directories.

bash
npx tsc --build --clean

Output:

text
(no output — exit code 0; deletes dist/, .d.ts, and .tsbuildinfo files)

--force rebuilds every project from scratch but keeps the directory structure — useful when you suspect a stale cache without wanting to delete file watchers' state in your editor:

bash
npx tsc --build --force

Output:

text
[12:00:00] Building project '/repo/packages/shared/tsconfig.json'...
[12:00:02] Building project '/repo/packages/api/tsconfig.json'...
[12:00:03] Building project '/repo/packages/ui/tsconfig.json'...
[12:00:04] Building project '/repo/packages/app/tsconfig.json'...

Combine --clean with a one-line guard in package.json:

jsonc
{
  "scripts": {
    "build": "tsc --build",
    "build:force": "tsc --build --force",
    "build:clean": "tsc --build --clean && tsc --build"
  }
}

Interop with bundlers

Bundlers (Vite, esbuild, Bun, webpack via ts-loader) don't understand references at runtime — they resolve imports against source files directly using their own resolver. The split is:

  • tsc --build handles type-checking and .d.ts emit.
  • Bundler handles JS bundling, dev server, HMR, and asset graph.

This means you typically run both in CI:

bash
npx tsc --build --noEmit && npx vite build

Output:

text
vite v5.4.0 building for production...
* 142 modules transformed.
dist/assets/index-Bx9D8.js  127.45 kB | gzip: 41.20 kB
* built in 1.84s

--noEmit on tsc --build skips writing dist/ files in CI (the bundler writes them instead) while still type-checking the whole graph. Some teams flip this: use tsc --build for emit during library publishing, and tsc --build --noEmit only in the app's CI step.

The one gotcha — bundler paths mapping and tsc's references must agree. If app/tsconfig.json has a paths: { "@repo/ui/*": ["../ui/src/*"] } entry, the bundler will resolve to source while tsc resolves to the referenced project's dist/. They will type-check differently. The safest setup: drop paths for cross-package imports and let workspaces + references do the job.

Composite + Turborepo / Nx

Turborepo and Nx layer task caching on top of npm/pnpm scripts. They don't replace project references — they invoke them. The typical pattern: tsc --build per package as the cacheable task, with the task graph mirroring the references graph.

jsonc
// turbo.json
{
  "tasks": {
    "build": {
      "dependsOn": ["^build"],
      "outputs": ["dist/**", ".tsbuildinfo"]
    },
    "typecheck": {
      "dependsOn": ["^typecheck"],
      "outputs": []
    }
  }
}

Each package's package.json:

jsonc
{
  "scripts": {
    "build": "tsc --build",
    "typecheck": "tsc --build --noEmit"
  }
}

Running turbo build from the root:

bash
npx turbo run build

Output:

text
* Packages in scope: @repo/api, @repo/app, @repo/shared, @repo/ui
* Running build in 4 packages
* Remote caching disabled

@repo/shared:build: cache miss, executing...
@repo/api:build: cache miss, executing...
@repo/ui:build: cache miss, executing...
@repo/app:build: cache miss, executing...

 Tasks:    4 successful, 4 total
Cached:    0 cached, 4 total
  Time:    4.8s

Re-run with no changes — both Turborepo's cache and tsc's .tsbuildinfo hit:

bash
npx turbo run build

Output:

text
@repo/shared:build: cache hit, replaying logs
@repo/api:build: cache hit, replaying logs
@repo/ui:build: cache hit, replaying logs
@repo/app:build: cache hit, replaying logs

 Tasks:    4 successful, 4 total
Cached:    4 cached, 4 total
  Time:    198ms

Path mapping in a reference graph

When a referencing project uses paths to alias imports, the alias must point at the referenced project's outDir, not its src/, or the type-checker will read fresh source while runtime reads compiled output — a classic source of "works locally, fails in CI" bugs.

jsonc
// packages/app/tsconfig.json — correct
{
  "compilerOptions": {
    "baseUrl": ".",
    "paths": {
      "@repo/shared": ["../shared/dist/index.d.ts"],
      "@repo/shared/*": ["../shared/dist/*"]
    }
  },
  "references": [{ "path": "../shared" }]
}

The cleaner alternative — and the one most monorepos converge on — is to skip paths entirely and rely on workspaces. Each package's package.json exports its compiled output:

jsonc
// packages/shared/package.json
{
  "name": "@repo/shared",
  "main": "dist/index.js",
  "types": "dist/index.d.ts",
  "exports": {
    ".": {
      "types": "./dist/index.d.ts",
      "import": "./dist/index.js"
    }
  }
}

Now import { greet } from "@repo/shared" works in both tsc --build and your bundler with zero configuration overlap.

Gradual JS-to-TS migration

Project references shine when migrating a large JS codebase to TypeScript. Carve off a single directory, give it a composite tsconfig with allowJs: true and checkJs: false, and reference it from the rest of the project. The migrated directory gets strict checks; everything else stays untouched.

jsonc
// src/payments/tsconfig.json — newly TS'd module
{
  "compilerOptions": {
    "composite": true,
    "outDir": "dist",
    "rootDir": ".",
    "target": "ES2022",
    "module": "NodeNext",
    "strict": true,
    "allowJs": false,
    "skipLibCheck": true
  },
  "include": ["**/*.ts"]
}
jsonc
// tsconfig.json — root, still mostly JS
{
  "compilerOptions": {
    "target": "ES2022",
    "module": "NodeNext",
    "allowJs": true,
    "checkJs": false,
    "strict": false,
    "noEmit": true
  },
  "references": [
    { "path": "./src/payments" }
  ],
  "include": ["src/**/*"]
}

The migrated module type-checks under strict: true; the rest of the repo stays loosely-typed. Migrate one directory at a time, and each move only requires adding another { "path": "..." } entry.

Common pitfalls

  1. Forgot composite: true on a referenced project — error: Referenced project must have setting "composite": true. Fix: add it to the leaf tsconfig.
  2. Referenced project's rootDir excludes the importing project — error: File '...' is not under 'rootDir'. Fix: either widen rootDir or move the file under the package's src/.
  3. Mixed noEmit settings — if a leaf composite project has noEmit: true, the referencing project can't read .d.ts files because none were written. Only the consumer (app, leaf binary) should set noEmit; library packages must emit.
  4. paths shortcuts that bypass references — if paths resolves @repo/ui to ../ui/src/index.ts instead of ../ui/dist/index.d.ts, you lose the dependency-order build and .tsbuildinfo invalidation gets confused. Use workspaces, not paths, for cross-package imports.
  5. Bundler and tsc resolving differently — Vite's moduleResolution: "bundler" allows extensionless imports, but tsc with module: "NodeNext" requires explicit .js extensions. Pick one resolution style and keep both sides in sync.
  6. Stale .tsbuildinfo after switching git branches — if a file's timestamp doesn't change but its content does, the cache may miss. Fix: tsc --build --force once after big branch switches. Better: configure the cache path inside a per-branch directory (.cache/<branch>/tsbuildinfo).
  7. Putting references inside a regular tsconfig and expecting tsc (not --build) to honour it — plain tsc ignores references entirely. You must use tsc --build (or tsc -b) for the reference graph to matter.
  8. composite + glob include with generated files — the constraint that every input must be matched by include means generated .ts files (from codegen) must be globbed too, or listed in files: [...]. Otherwise they error with File '...' is not in project file list.

Real-world recipes

Three-package monorepo with strict CI

Build, type-check, and publish a workspace of three composite packages plus a non-emitting app. Each package.json exports compiled output; the root package.json has scripts that operate on the whole graph.

jsonc
// package.json (root)
{
  "name": "@repo/root",
  "private": true,
  "workspaces": ["packages/*"],
  "scripts": {
    "build": "tsc --build",
    "watch": "tsc --build --watch",
    "typecheck": "tsc --build --noEmit",
    "clean": "tsc --build --clean",
    "ci": "tsc --build && vitest run"
  },
  "devDependencies": {
    "typescript": "^5.4.5",
    "vitest": "^2.0.0",
    "vite": "^5.4.0"
  }
}
bash
npm run ci

Output:

text
> tsc --build && vitest run

  Test Files  6 passed (6)
       Tests  42 passed (42)
    Duration  3.21s

Catch stale .tsbuildinfo in CI

A common CI gotcha — the GitHub Actions cache might contain a .tsbuildinfo that disagrees with the checked-out source. Use --dry --verbose first to assert that the cache is valid; if anything is "out of date" unexpectedly, fail the job.

bash
# Step 1: dry-run to inspect cache validity
npx tsc --build --dry --verbose | tee build-plan.txt

# Step 2: if the plan says everything is up to date, proceed; else force-rebuild
if grep -q "is out of date" build-plan.txt; then
  npx tsc --build --force
else
  npx tsc --build
fi

Output:

text
[12:00:00] Project 'packages/shared/tsconfig.json' is up to date because newest input is older than output
[12:00:00] Project 'packages/api/tsconfig.json' is up to date with .d.ts files from its dependencies
[12:00:00] Project 'packages/ui/tsconfig.json' is up to date with .d.ts files from its dependencies
[12:00:00] Project 'packages/app/tsconfig.json' is up to date with .d.ts files from its dependencies

Publish a single composite package to npm

A composite package's package.json should prepublishOnly build itself before publishing, ensuring dist/ is fresh. Pair files (controls what's published) with exports (controls how consumers resolve).

jsonc
// packages/shared/package.json
{
  "name": "@repo/shared",
  "version": "1.0.0",
  "type": "module",
  "main": "dist/index.js",
  "types": "dist/index.d.ts",
  "exports": {
    ".": {
      "types": "./dist/index.d.ts",
      "import": "./dist/index.js"
    }
  },
  "files": ["dist", "!dist/.tsbuildinfo"],
  "scripts": {
    "build": "tsc --build",
    "prepublishOnly": "tsc --build --force"
  }
}
bash
cd packages/shared && npm publish --access public

Output:

text
> tsc --build --force

npm notice
npm notice * @repo/shared@1.0.0
npm notice === Tarball Contents ===
npm notice 412B  dist/index.d.ts
npm notice 234B  dist/index.js
npm notice 612B  package.json
npm notice === Tarball Details ===
npm notice name:          @repo/shared
npm notice version:       1.0.0
npm notice filename:      repo-shared-1.0.0.tgz
+ @repo/shared@1.0.0

Note the !dist/.tsbuildinfo glob — it keeps the cache file out of the published tarball so consumers don't see implementation noise.

Migrate src/payments from JS to TS

A surgical migration where only one directory gets strict TypeScript and the rest of the codebase stays as untouched JavaScript. Root tsconfig stays permissive (allowJs, no strict checks); the new module's tsconfig is fully strict and composite.

bash
mkdir -p src/payments
cat > src/payments/tsconfig.json <<'EOF'
{
  "compilerOptions": {
    "composite": true,
    "target": "ES2022",
    "module": "NodeNext",
    "rootDir": ".",
    "outDir": "../../dist/payments",
    "strict": true,
    "noUncheckedIndexedAccess": true,
    "exactOptionalPropertyTypes": true,
    "skipLibCheck": true
  },
  "include": ["**/*.ts"]
}
EOF

# Add reference to the root tsconfig manually, then build
npx tsc --build src/payments --verbose

Output:

text
[12:00:00] Projects in this build:
    * src/payments/tsconfig.json

[12:00:00] Project 'src/payments/tsconfig.json' is out of date because output file '../../dist/payments/.tsbuildinfo' does not exist

[12:00:00] Building project '/repo/src/payments/tsconfig.json'...

[12:00:01] Done.

Each subsequent migrated directory gets its own composite tsconfig and another { "path": "..." } entry in the root references array — the cache stays warm for everything except the freshly-migrated module.