Project References and Build
|
This section documents the current TypeScript release line as published at the official TypeScript documentation, which is the reference these pages are written and verified against. No specific patch version is pinned. This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production, since TypeScript iterates quickly. This section’s bibliography lists the reference material consulted while preparing these pages. |
A single tsconfig.json compiles one program. Project references let one tsconfig.json depend on
others, so a monorepo builds as a graph of small programs that each cache their own output. The
Project References handbook chapter is
the companion to this page; what follows is the working summary, plus how to keep type-checking honest when
another tool does the actual emit.
composite, references, and tsc -b
A referenced project must set composite: true (which forces declaration: true and a rootDir), and the
depending project lists it under references. tsc -b (alias for tsc --build) then topologically sorts
the graph, skips projects whose inputs are unchanged, and builds the rest in order.
A concrete monorepo: packages/core (no deps), packages/api (depends on core), packages/web
(depends on core and api).
// tsconfig.base.json -- shared compiler options
{
"compilerOptions": {
"target": "es2022",
"module": "nodenext",
"moduleResolution": "nodenext",
"strict": true,
"declaration": true,
"declarationMap": true,
"sourceMap": true,
"composite": true,
"incremental": true
}
}
// packages/core/tsconfig.json -- a leaf, no references
{
"extends": "../../tsconfig.base.json",
"compilerOptions": { "outDir": "dist", "rootDir": "src" },
"include": ["src"]
}
// packages/api/tsconfig.json -- adds one reference
{
"extends": "../../tsconfig.base.json",
"compilerOptions": { "outDir": "dist", "rootDir": "src" },
"include": ["src"],
"references": [{ "path": "../core" }]
}
// packages/web/tsconfig.json -- depends on both
{
"extends": "../../tsconfig.base.json",
"compilerOptions": { "outDir": "dist", "rootDir": "src" },
"include": ["src"],
"references": [{ "path": "../core" }, { "path": "../api" }]
}
// tsconfig.json at the repo root -- a "solution" file that builds everything
{
"files": [],
"references": [
{ "path": "packages/core" },
{ "path": "packages/api" },
{ "path": "packages/web" }
]
}
# Build the whole graph in dependency order, skipping unchanged projects.
npx tsc -b
# Same thing, targeting one leaf -- its dependencies are built first as needed.
npx tsc -b packages/web
# Show what would be built and why, without writing anything.
npx tsc -b --dry
# Explain each up-to-date / out-of-date decision.
npx tsc -b --verbose
# Delete every project's outputs and .tsbuildinfo files.
npx tsc -b --clean
# Rebuild-on-change across the whole graph.
npx tsc -b --watch
Import across packages by their package name (import \{ x } from "@scope/core"), not by relative path into
dist; declarationMap: true makes editor "Go to Definition" jump to the dependency’s .ts source rather
than its emitted .d.ts. Package-name resolution is a modules concern — each package.json needs "main"/"exports" pointing at dist.
.tsbuildinfo and incremental
incremental: true writes a .tsbuildinfo file (next to outDir, or at
tsBuildInfoFile) recording file hashes and the last program’s signatures. The next run reads it and
recompiles only what changed. composite: true implies incremental: true, so every referenced project
gets this cache for free; a standalone project can opt in with just incremental and no references.
# First build: full compile.
npx tsc -b --verbose
# [projects] building packages/core, packages/api, packages/web
# Touch one file in core, build again:
npx tsc -b --verbose
# project 'packages/core' is out of date because ... -- rebuilds core
# project 'packages/api' is out of date because output of 'core' changed
# project 'packages/web' ... likewise
# Unchanged projects report "up to date" and are skipped.
Commit-time or CI caching of .tsbuildinfo (or restoring outDir + .tsbuildinfo from a cache key) turns
a cold tsc -b into a near-no-op when nothing changed. Delete the file (or tsc -b --clean) if incremental
state ever looks stale.
Source maps for debugging
sourceMap: true emits a .js.map beside each .js so debuggers show your .ts. Two path knobs matter
when the served layout differs from the build layout: sourceRoot prefixes every source path recorded in
the map (point it at where the browser or editor can fetch the .ts files), and mapRoot tells the
emitted .js where its .map lives (via the //# sourceMappingURL= comment) when the map is served from a
different directory than the script.
{
"compilerOptions": {
"sourceMap": true,
"sourceRoot": "/@src", // browser resolves original sources under this URL
"mapRoot": "/maps", // .js files point their sourceMappingURL here
"inlineSources": true // embed the .ts text in the map -- no separate fetch
}
}
For the browser, ship the .map files (or use inlineSourceMap) and enable "Enable JavaScript source
maps" in DevTools; breakpoints then bind to .ts. Bundlers usually take over source-map generation — see
Building with other tools below.
For Node, run with --enable-source-maps so stack traces point at .ts lines and columns:
# Node applies the .js.map files at runtime; no extra deps.
node --enable-source-maps dist/api/server.js
# Also honored via env var (useful in Docker / process managers):
NODE_OPTIONS=--enable-source-maps node dist/api/server.js
declarationMap: true is the type-level counterpart: it maps .d.ts back to .ts so cross-project "Go to
Definition" and refactors land in source.
Compiler performance
When tsc feels slow, measure before changing anything.
# High-level counts: files, lines, symbols, memory, and phase timings.
npx tsc --noEmit --diagnostics
# Adds parse/bind/check/emit breakdown and I/O read/write times.
npx tsc --noEmit --extendedDiagnostics
# Emit a trace/ directory: trace.<n>.json (load in chrome://tracing or
# https://ui.perfetto.dev) plus types.<n>.json for the type registry.
npx tsc --noEmit --generateTrace trace
# Analyze the trace for the most expensive type instantiations.
npx analyze-trace trace
Common wins:
-
skipLibCheck: true— skip type-checking of all.d.ts(yours are still checked on emit). Almost always worth it; it removes conflicting/expensive@typesfrom the check. -
Keep types shallow. Deep conditional and mapped-type recursion, huge union types, and large inferred object literals dominate check time; give hot paths explicit, named types instead of letting the compiler synthesize giant structural ones.
-
import type \{ Foo }/import \{ type Foo }— mark type-only imports so they carry no runtime edge and cannot drag a module into the JS graph. See tsconfig and compiler options forverbatimModuleSyntax, which enforces this. -
Project references themselves — a change in
packages/webno longer re-checkspackages/core.
The native port (the "7.0" TypeScript compiler, tsc rewritten in Go) runs project loads and type-checking
roughly 10x faster than the current JavaScript implementation with the same type system and semantics — same tsconfig.json, same errors. It is a drop-in speedup, not a language change.
Building with other tools
Babel, esbuild, SWC, and Vite compile TypeScript by stripping type annotations per file — they are fast
precisely because they do no type-checking and never load the full program. They will happily emit code
that tsc would reject. The rule: let the bundler emit, and run tsc --noEmit as a separate gate in CI.
// package.json
{
"scripts": {
"build": "vite build",
"typecheck": "tsc -b --noEmit",
"test": "vitest run",
"ci": "npm run typecheck && npm run build && npm run test"
}
}
# esbuild / SWC / Babel: transpile only, zero type errors reported.
npx esbuild src/index.ts --bundle --sourcemap --outfile=dist/index.js
npx swc src -d dist --source-maps
npx babel src --extensions .ts,.tsx --out-dir dist --source-maps
# The type-check that none of the above perform -- keep it in CI.
npx tsc -b --noEmit # whole reference graph
npx tsc --noEmit --watch # local feedback loop while the bundler serves
# Vite: `vite build` never type-checks; wire tsc alongside it.
npx vite build && npx tsc -b --noEmit
Because these transpilers see one file at a time, constructs that need type information fail or misbehave:
const enum inlining, legacy experimentalDecorators metadata, and namespace merging. Prefer plain
enum or literal unions, standard decorators, and ES modules. isolatedModules: true in tsconfig.json
makes tsc flag exactly these single-file-unsafe patterns. See
Babel and
bundling and npm publishing for the JS-side
setup, and the
Integrating with Build
Tools handbook page for per-tool plugins. Watch-mode tuning (polling, excluded directories) is covered in
Configuring Watch.
The build graph
tsc -b walks references depth-first, builds each project only after its dependencies, and skips any
whose .tsbuildinfo says its inputs are unchanged.
composite: true"] api["packages/api
references: core"] web["packages/web
references: core, api"] root["tsconfig.json (solution)
files: [], references: all"] core --> api core --> web api --> web web --> root subgraph order["tsc -b build order"] direction LR s1["1. core"] --> s2["2. api"] --> s3["3. web"] end
See also
-
tsconfig and compiler options —
composite,incremental,declarationMap,verbatimModuleSyntax,isolatedModules, and the rest of the flags used here. -
Modules — package-name resolution,
exportsmaps, andmoduleResolutionsettings that cross-package imports depend on. -
Babel and Bundling and npm publishing — the JS-side emit pipeline and shipping
distplus its.d.tsfiles.