TypeScript config and tooling · 7 / 8
lesson 7

Project references

Splitting a monorepo into typed sub-projects that build in the right order.

~ 15 min read·lesson 7 of 8
0 / 8

You've got a packages/ui folder, a packages/api folder, and an apps/web folder that uses both. They all have their own tsconfig.json. You change a type in ui, and web doesn't see the update until you restart the editor. The dev loop is fine, but tsc --noEmit from the root takes 40 seconds because it re-checks everything from scratch on every run. There's a better setup: project references. They're not new, just unfamiliar — and they fix exactly the problem you're feeling.

This lesson walks through the setup, the rules, and where the speed actually comes from.

The problem references solve

Project references let you split one big TypeScript project into several smaller ones, with explicit dependencies between them, and have the compiler treat them as a build graph.

The benefits stack up:

  • Incremental builds. When ui hasn't changed, the compiler skips rechecking it. Only the parts downstream of an actual change run.
  • Enforced boundaries. web can only see the parts of ui that ui's tsconfig actually exports. Cross-cutting "I'll just import from anywhere" gets caught.
  • Faster editor. The language server can lazily load the projects you're not currently editing.
  • Independent tsconfigs. api can be target: "ES2020", web can be target: "ES2022", without conflict.

The cost is some extra config and a different build command. The rest of this lesson is that config.

composite — making a project referable

For a sub-project to be referenced by another sub-project, it needs composite: true in its tsconfig.json.

composite: true is a bundle that turns on:

  • declaration: true — the project must emit .d.ts files (so referrers can read its types).
  • incremental: true — the project caches its build state (so re-checks are fast).
  • A few stricter include/exclude rules.
packages/ui/tsconfig.json
{
"compilerOptions": {
  "composite": true,
  "rootDir": "src",
  "outDir": "dist",
  "declaration": true,
  "target": "ES2022",
  "module": "ESNext",
  "moduleResolution": "bundler"
},
"include": ["src"]
}

That config makes packages/ui a valid reference target. When something else references it, the compiler will:

  1. Build packages/ui if it's stale.
  2. Read the resulting dist/*.d.ts files for type information.
  3. Skip rechecking ui's source on subsequent runs unless the source changed.

That third point is where the speed lives. The compiler keeps a .tsbuildinfo file in the output folder that tracks which files contributed to the last build. Next run, it compares timestamps and only rebuilds what changed.

Tip

composite: true implies several other flags. You don't need to set declaration: true separately — composite is the umbrella.

references — the build graph

A project that uses another sub-project declares the dependency in its references array. Each entry is a relative path to the referenced project's tsconfig.

apps/web/tsconfig.json
{
"compilerOptions": {
  "target": "ES2022",
  "module": "ESNext",
  "moduleResolution": "bundler",
  "jsx": "react-jsx",
  "strict": true
},
"include": ["src"],
"references": [
  { "path": "../../packages/ui" },
  { "path": "../../packages/api" }
]
}

That tells the compiler: "Before checking web, make sure ui and api are built. Read their declarations from their respective outDir. Don't reach into their sources directly."

That last rule is the boundary. With references, when web writes import { Button } from '@scope/ui', the compiler resolves that against packages/ui/dist/*.d.ts — the built output. Internal modules of ui that aren't part of its emitted types are invisible to web, even if you tried to import them by relative path.

The whole graph, top to bottom, looks like this:

apps/webuiapishared
Each project references the ones it depends on. The compiler builds in topological order.

shared builds first because nothing depends on it. api builds next, reading shared's declarations. ui builds (independent of api). web builds last, reading both. Change a file in shared and the compiler rebuilds shared, then api, then web — but skips ui entirely because it didn't depend on the changed code.

check your understanding
Your monorepo has web referencing ui, and ui referencing shared. You change a file in shared. Which projects must rebuild?

tsc --build, the orchestrator

To build a graph of referenced projects, you don't run plain tsc. You run tsc --build (or tsc -b).

terminal
# build everything reachable from the root tsconfig
tsc --build

# clean all build artifacts in the graph
tsc --build --clean

# rebuild from scratch even if cached
tsc --build --force

# watch mode across the whole graph
tsc --build --watch

tsc --build reads the references field in the tsconfig you point it at, recursively walks every referenced project, and builds them in topological order. Each project that has a valid .tsbuildinfo from a previous run gets skipped if its inputs haven't changed.

You typically have a root tsconfig.json that exists only to list every project in the workspace as a reference. It has no source files of its own.

tsconfig.json
{
"files": [],
"references": [
  { "path": "./packages/shared" },
  { "path": "./packages/ui" },
  { "path": "./packages/api" },
  { "path": "./apps/web" }
]
}

That root config is the entry point. tsc --build from the repo root walks the references, figures out the order, and builds the world.

Watch out

Plain tsc (no --build) ignores the references field. If you set up project references and run tsc the old way, the dependencies aren't built and you get cryptic "Cannot find module" errors for things that exist.

check your understanding
You have project references set up. You run tsc --noEmit and get errors saying types from packages/ui can't be found. Why?

When this is worth the setup

Project references trade a one-time config cost for ongoing build speed. The trade is good in a few specific shapes:

  • Monorepos with multiple apps and shared packages. Three or more sub-projects with type dependencies between them — references pay off within a week.
  • Big single repos that split into "library" and "app" halves. Even one boundary between two folders gets you most of the speed benefit.
  • Codebases where tsc --noEmit takes more than ten seconds. That is the threshold where incremental rechecking starts to feel different.

Where it isn't worth it:

  • A single app project under 200 files. The setup overhead exceeds the speed gain.
  • A library that publishes one bundle. A single tsconfig with no references is simpler and equally fast.

The honest answer for most small projects is: don't bother yet. Add references the first time you find yourself opening three editor windows on three sub-projects and getting tired of the round-trip lag.

check your understanding
Which of these is a real benefit of project references that a single tsconfig can't provide?
check your understanding
You set composite: true on packages/ui. You forgot declaration: true. Will web see ui's types after a build?
← prevnext lesson →
KeepLearningcertificate
for completing
TypeScript config and tooling
0 of 8 read