Project References and Monorepos

Advanced
12 min

Project References and Monorepos

When one repository holds several packages (a shared types library, an API, a web client), a single tsconfig.json becomes slow and blurry: every change rechecks everything, and nothing stops the client from importing server internals. Project references split the codebase into projects with explicit dependencies, and tsc -b builds them in order and incrementally. This lesson shows how to structure such a repository, wire it up with npm workspaces, and diagnose the errors that appear along the way.

What a Project Reference Is

A project is any folder with its own tsconfig.json. One project references another with the references array, which tells the compiler three things:

  • Imports from the referenced project resolve to its emitted declaration files, not its sources, so type information is cached.
  • The referenced project must be built first; tsc -b orders builds automatically.
  • Edits inside the referenced project do not force a full recheck of dependents unless its public types changed.

Referenced projects must set "composite": true, which in turn requires declaration: true, that every source file is covered by include or files, and that rootDir is set (it defaults to the folder containing the tsconfig).

Build Mode: tsc -b

Build mode treats tsconfig.json files as a graph:

bash
npx tsc -b # build root solution and all references npx tsc -b apps/api # build one project and its dependencies npx tsc -b --watch # rebuild on change across all projects npx tsc -b --clean # delete outputs npx tsc -b --force # ignore up-to-date checks and rebuild all npx tsc -b --verbose # print which projects are rebuilt and why

Each project writes a .tsbuildinfo file recording what it last compiled. On the next run, unchanged projects are skipped entirely, which is what makes large repositories fast. Note that tsc -b does not accept most compiler flags on the command line; options live in the tsconfig files.

Repository Layout With npm Workspaces

Project references handle types; workspaces handle packages. Together they let apps/api import @acme/shared like any npm dependency:

json
// package.json at the root { "private": true, "workspaces": ["packages/*", "apps/*"], "scripts": { "build": "tsc -b", "build:watch": "tsc -b --watch" } }
json
// packages/shared/package.json { "name": "@acme/shared", "type": "module", "exports": { ".": { "types": "./dist/index.d.ts", "import": "./dist/index.js" } } }

npm install symlinks @acme/shared into node_modules, so import { User } from "@acme/shared" resolves through exports to the built declarations. Add "@acme/shared": "*" to the API's dependencies and run tsc -b before starting it. pnpm and Yarn workspaces work identically.

An alternative for small repos is paths mapping ("@acme/shared/*": ["packages/shared/src/*"]), which points at sources instead of built output; it is simpler but bypasses the caching that references provide and needs matching bundler configuration.

Sharing Configuration

Put common options in tsconfig.base.json and extends it from every project, as in the sample. Only per-project settings (rootDir, outDir, include, references, lib for browser packages) stay local. TypeScript 5.5 added the ${configDir} variable so a base file can express paths relative to the extending config:

json
{ "compilerOptions": { "outDir": "${configDir}/dist", "rootDir": "${configDir}/src" } }

With that, even rootDir and outDir can live in the base file.

Common Errors

| Message | Fix | |---|---| | Referenced project must have setting "composite": true | Add composite (and declaration) to the referenced project | | Output file ... has not been built from source file | Run tsc -b so the dependency's .d.ts exists | | File ... is not listed within the file list of project | Every source must be matched by the project's include | | Cannot write file ... because it would overwrite input file | Set outDir, or exclude dist from include |

Editors that use the TypeScript language service load only the projects that the open files belong to, so references also keep large repositories responsive in VS Code. Task runners such as Turborepo or Nx can wrap tsc -b with remote caching, but the compiler's own incremental build is enough for many teams.

Tips

  • Keep one direction of dependency: apps depend on packages, packages never import apps.
  • Add .tsbuildinfo files and dist/ folders to .gitignore; they are machine-local build outputs.
  • Run tsc -b in CI before tests so stale declarations cannot hide type errors.
Quick Quiz
Question 1 of 3

Which compiler option must a referenced project enable?

Key Takeaways

  • Project references split a repository into projects with explicit dependencies and cached declaration output.
  • Referenced projects need composite: true; tsc -b builds them in order and incrementally.
  • npm, pnpm or Yarn workspaces link local packages so apps import shared code as normal dependencies.
  • Centralise options in tsconfig.base.json with extends; ${configDir} keeps paths relative to each project.
  • A root solution tsconfig with "files": [] and references lets one tsc -b build everything.

Next lesson: Migrating a JavaScript Project to TypeScript — adopt TypeScript incrementally without stopping feature work.

Project References and Monorepos - TypeScript | CodeYourCraft | CodeYourCraft