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.
# 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.
npx tsc --build --help | head -3
Output:
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.
tsc --build [project...] [--watch] [--clean] [--force] [--dry] [--verbose]
Output: (none — exits 0 on success)
Essential flags
| Flag | Meaning |
|---|---|
-b, --build | Treat inputs as projects to build (rather than files to compile). |
--watch, -w | Watch all referenced projects and rebuild on change. |
--clean | Delete every outDir, .d.ts, and .tsbuildinfo for the project graph. |
--force | Rebuild every project even if .tsbuildinfo says it is up to date. |
--dry | Print what would be built without actually building. |
--verbose | Explain why each project is or isn't being rebuilt. |
--incremental | Enable .tsbuildinfo cache (implied by composite). |
--listFiles | Print 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.
// 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.
npx tsc --build packages/shared --verbose
Output:
[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".
// 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.
// 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.
// 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:
npx tsc --build
Output:
(no output — exit code 0 when all projects type-check cleanly)
Add --verbose to see the dependency order TypeScript computed:
npx tsc --build --verbose
Output:
[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.
# First build — everything compiles
npx tsc --build --verbose | grep -E "Building|up to date"
Output:
[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:
echo "// touch" >> packages/shared/src/index.ts
npx tsc --build --verbose | grep -E "Building|up to date"
Output:
[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:
{
"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.
npx tsc --build --watch
Output:
[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:
[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.
.
|-- 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.
{
"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:
npx tsc --build && npx vite --config packages/app/vite.config.ts
Output:
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.
npx tsc --build --clean
Output:
(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:
npx tsc --build --force
Output:
[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:
{
"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 --buildhandles type-checking and.d.tsemit.- Bundler handles JS bundling, dev server, HMR, and asset graph.
This means you typically run both in CI:
npx tsc --build --noEmit && npx vite build
Output:
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.
// turbo.json
{
"tasks": {
"build": {
"dependsOn": ["^build"],
"outputs": ["dist/**", ".tsbuildinfo"]
},
"typecheck": {
"dependsOn": ["^typecheck"],
"outputs": []
}
}
}
Each package's package.json:
{
"scripts": {
"build": "tsc --build",
"typecheck": "tsc --build --noEmit"
}
}
Running turbo build from the root:
npx turbo run build
Output:
* 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:
npx turbo run build
Output:
@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.
// 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:
// 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.
// 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"]
}
// 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
- Forgot
composite: trueon a referenced project — error:Referenced project must have setting "composite": true. Fix: add it to the leaf tsconfig. - Referenced project's
rootDirexcludes the importing project — error:File '...' is not under 'rootDir'. Fix: either widenrootDiror move the file under the package'ssrc/. - Mixed
noEmitsettings — if a leaf composite project hasnoEmit: true, the referencing project can't read.d.tsfiles because none were written. Only the consumer (app, leaf binary) should setnoEmit; library packages must emit. pathsshortcuts that bypass references — ifpathsresolves@repo/uito../ui/src/index.tsinstead of../ui/dist/index.d.ts, you lose the dependency-order build and.tsbuildinfoinvalidation gets confused. Use workspaces, not paths, for cross-package imports.- Bundler and
tscresolving differently — Vite'smoduleResolution: "bundler"allows extensionless imports, buttscwithmodule: "NodeNext"requires explicit.jsextensions. Pick one resolution style and keep both sides in sync. - Stale
.tsbuildinfoafter switching git branches — if a file's timestamp doesn't change but its content does, the cache may miss. Fix:tsc --build --forceonce after big branch switches. Better: configure the cache path inside a per-branch directory (.cache/<branch>/tsbuildinfo). - Putting
referencesinside a regular tsconfig and expectingtsc(not--build) to honour it — plaintscignoresreferencesentirely. You must usetsc --build(ortsc -b) for the reference graph to matter. composite+ globincludewith generated files — the constraint that every input must be matched byincludemeans generated.tsfiles (from codegen) must be globbed too, or listed infiles: [...]. Otherwise they error withFile '...' 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.
// 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"
}
}
npm run ci
Output:
> 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.
# 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:
[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).
// 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"
}
}
cd packages/shared && npm publish --access public
Output:
> 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.
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:
[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.