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.
A project is any folder with its own tsconfig.json. One project references another with the references array, which tells the compiler three things:
tsc -b orders builds automatically.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 treats tsconfig.json files as a graph:
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 whyEach 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.
Project references handle types; workspaces handle packages. Together they let apps/api import @acme/shared like any npm dependency:
// package.json at the root
{
"private": true,
"workspaces": ["packages/*", "apps/*"],
"scripts": { "build": "tsc -b", "build:watch": "tsc -b --watch" }
}// 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.
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:
{
"compilerOptions": {
"outDir": "${configDir}/dist",
"rootDir": "${configDir}/src"
}
}With that, even rootDir and outDir can live in the base file.
| 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.
.tsbuildinfo files and dist/ folders to .gitignore; they are machine-local build outputs.tsc -b in CI before tests so stale declarations cannot hide type errors.Which compiler option must a referenced project enable?
composite: true; tsc -b builds them in order and incrementally.tsconfig.base.json with extends; ${configDir} keeps paths relative to each project."files": [] and references lets one tsc -b build everything.Next lesson: Migrating a JavaScript Project to TypeScript — adopt TypeScript incrementally without stopping feature work.