Back to monorepo orchestration Target affected workspaces Configure turbo pipelines Speed up type-checking

TypeScript Project References in Monorepos

Type-checking a monorepo package by package with tsc --noEmit repeats work: every package re-checks its dependencies' source from scratch, editors load the whole repository into one language service, and a change to a leaf package re-checks everything. TypeScript project references split the repository into projects that build in dependency order, reuse each other's emitted declarations, and skip anything unchanged. Set up well, they make tsc -b the fastest correct type-check for the whole repository and keep editors responsive. Set up badly, they produce confusing errors about files "not built from source" and duplicate type-checking. This section explains how references work, how they fit with package managers and task runners, and how to adopt them incrementally.

What project references solve

Without references, TypeScript knows nothing about package boundaries. When apps/web imports @acme/ui, the compiler either resolves the package's emitted .d.ts (which must be built first by something else) or, through paths aliases, the package's source (which it then type-checks as part of apps/web). Both approaches have costs:

# Without references, type-checking each package separately
$ time pnpm -r exec tsc --noEmit
packages/utils  ~4s
packages/ui     ~9s   (re-checks utils source through paths)
apps/web        ~21s  (re-checks ui and utils source again)
real    0m34s

With references, each project is checked once, its declarations are emitted, and dependents read those declarations instead of re-checking the source. Unchanged projects are skipped entirely using .tsbuildinfo files. The broader monorepo context is covered in Monorepo Architecture & Orchestration.

A project reference graph The root solution tsconfig references the apps; apps reference ui and api-client; those reference utils. tsc -b builds from the leaves upward. packages/utils composite packages/ui references utils packages/api-client references utils apps/web references ui, api-client
tsc -b walks references depth-first, building utils first and the apps last, skipping anything up to date.

Concept overview

Three compiler features work together:

  • composite: true marks a project as referenceable. It forces declaration: true, requires every source file to be matched by include or files, and enables incremental build info.
  • references in a project's tsconfig.json list the projects it depends on. TypeScript then resolves imports of those projects to their emitted declarations, not their source.
  • tsc --build (tsc -b) builds a project and, recursively, everything it references, in dependency order, skipping projects whose inputs have not changed since the last build.

The guides in this section go deeper on each part: Configuring tsc --build with Composite Projects sets up the configuration, Fixing TS6305 'Output File Has Not Been Built From Source' handles the most common error, Using Internal Packages Without a Build Step covers the alternative model, and Speeding Up Type-Checking in Large Monorepos tunes performance.

Core initialisation and configuration

Each package gets a tsconfig.json that is composite and references its dependencies:

// packages/ui/tsconfig.json
{
  "extends": "@acme/tsconfig/library.json",
  "compilerOptions": {
    "composite": true,
    "rootDir": "src",
    "outDir": "dist",
    "tsBuildInfoFile": "dist/.tsbuildinfo"
  },
  "include": ["src"],
  "references": [{ "path": "../utils" }]
}

The root holds a solution file that compiles nothing itself but references every project, so one command builds everything and editors discover the whole graph:

// tsconfig.json (root)
{
  "files": [],
  "references": [
    { "path": "packages/utils" },
    { "path": "packages/ui" },
    { "path": "packages/api-client" },
    { "path": "apps/web" }
  ]
}
# Build or type-check everything, incrementally
npx tsc -b

# One project and its references
npx tsc -b apps/web

# What would be rebuilt, and why
npx tsc -b --verbose --dry

The shared base configuration these extend is covered in Sharing a Base tsconfig Across Workspaces.

Adopting references incrementally

Converting a large repository to project references in one pull request is risky, because every package's configuration changes at once and the first tsc -b surfaces every latent configuration problem together. An incremental path works better.

  1. Start at the leaves. Pick packages with no internal dependencies — utilities, configuration, types — and make them composite. Nothing references them yet, so nothing else changes; you only confirm that each builds cleanly with tsc -b packages/utils.
  2. Move up one layer at a time. Make the next layer composite and add references to the leaves. Run tsc -b for the new layer; fix errors such as files outside include (TS6307) or missing declarations.
  3. Add the solution file at the root once most packages are composite, listing them as references. tsc -b from the root now builds the converted part of the graph.
  4. Convert applications last. Applications are leaves from the other side — nothing references them — so they can use noEmit or emit to a throwaway folder while referencing libraries.
  5. Remove cross-package paths aliases once every consumer references its dependencies, so there is exactly one way the compiler finds another package's types.

At every step, run the existing per-package type-check alongside tsc -b in CI until the two agree, then remove the old check.

Emit layout and what references need from it

Referenced projects must emit declarations, and consumers must be able to find those declarations through normal module resolution. In practice that means three settings agree with each other: rootDir and outDir in the project's tsconfig.json, the types conditions in the package's exports map, and the file layout in dist/. If rootDir is src and outDir is dist, then src/index.ts produces dist/index.d.ts, and the exports map must point types at ./dist/index.d.ts. A mismatch — for example, rootDir left unset so TypeScript infers a common root that includes a scripts/ folder — shifts output into dist/src/index.d.ts and breaks every consumer's resolution. Setting rootDir explicitly in every composite project prevents that entire class of problem.

Separate the declaration emit from the JavaScript build when a bundler produces the runtime files. A common arrangement is tsc -b emitting only declarations (emitDeclarationOnly: true) into dist/, while tsup, Vite or esbuild writes the JavaScript into the same folder. The two outputs must not overwrite each other, and the build info file should live in dist/ so cleaning dist/ also resets incremental state.

Architecture: how references resolve imports

References change what the compiler reads for an import. When apps/web imports @acme/ui, module resolution finds packages/ui through node_modules (a workspace symlink) and its exports map. Because packages/ui is a referenced project, TypeScript maps any resolved source file in that project to the corresponding output declaration in its outDir. If the output does not exist or is stale, and you are not running tsc -b, the compiler reports TS6305.

How tsc -b resolves an import across a reference Building apps/web first builds referenced projects, emitting declarations; when web imports @acme/ui the compiler reads ui's emitted d.ts instead of its source. tsc -b apps/web packages/utils packages/ui apps/web check build if stale: emit dist/*.d.ts build if stale: emit dist/*.d.ts type-check web import @acme/ui -> read dist/index.d.ts
Referenced projects are consumed through their declarations, which is why they must be built first.

Editors behave slightly differently by default: the TypeScript language service follows references to source files for navigation and uses them for checking when declarations are missing, so go-to-definition jumps into packages/ui/src rather than dist. That is controlled by disableSourceOfProjectReferenceRedirect, which large repositories sometimes enable to reduce editor memory use.

Build mode versus source-first internal packages

Project references assume that packages consume each other's declarations. An alternative model, popular in application-heavy monorepos, has packages consume each other's source: internal packages point exports at src/index.ts, and each application's bundler compiles everything. Both models are valid, and choosing between them is one of the more consequential architecture decisions in a TypeScript monorepo.

References suit repositories with many libraries, published packages, Node.js services that load compiled output, and teams that want each package type-checked once in isolation. Source-first suits repositories where nearly everything is consumed by bundled applications and fast feedback matters more than isolation; it removes build steps from the development loop but pays with larger type-check programs, because each application checks the source of everything it imports.

Hybrid setups are common and work well: libraries are composite projects checked with tsc -b, while their exports maps include a development condition pointing at source so application dev servers and tests skip the build. The details of the source-first side are in Using Internal Packages Without a Build Step.

Choosing a type-checking model for internal packages The consumers of internal packages decide between project references with built declarations, source-first packages, or a hybrid with a development condition. Who consumes internal packages? bundlers, Node.js services, or npm? Project references composite + tsc -b + dist Node.js or npm Source-first exports point at src bundlers only Hybrid dist + development condition both
Many repositories end up hybrid — declarations for type-checking and publishing, source for dev servers and tests.

Execution strategy: references alongside a task runner

Project references and a task runner overlap: both order work by the dependency graph and skip unchanged work. They can be combined in two ways:

  1. One tsc -b from the root for type-checking, as a single task. TypeScript's incremental build does the ordering and skipping; the task runner only caches the whole result. Simple and fast for type-checking.
  2. Per-package tsc -b tasks orchestrated by the task runner, each depending on ^typecheck. The task runner caches per package and can distribute work, while TypeScript's build info speeds up each package.

Keep the reference graph in sync with package.json dependencies — a package that depends on @acme/ui must also reference ../ui. Tools such as @monorepo-utils/workspaces-to-typescript-project-references or Nx's TypeScript plugin generate references from the workspace graph automatically.

Type-checking strategies in a monorepo Compares per-package tsc --noEmit, a single root tsc -b with project references, and task-runner-orchestrated tsc -b per package. tsc --noEmit per package root tsc -b task runner + tsc -b Checks each file once re-checks dependencies yes yes Skips unchanged projects no .tsbuildinfo cache + tsbuildinfo Shared between machines no only if artefacts cached remote cache Configuration effort none references references + tasks
Project references remove repeated checking; a task runner adds cross-machine caching on top.

Editors and large repositories

Project references also shape the editor experience, which is often the most noticeable benefit for developers. Without references, opening any file in a large monorepo can make the TypeScript language service load the entire repository as one program, which uses gigabytes of memory and slows every keystroke. With references, the language service loads the project that contains the open file and treats referenced projects as separate units, loading their source only when you navigate into them.

Two settings tune this. disableReferencedProjectLoad stops the language service from loading referenced projects eagerly, which helps in very large repositories at the cost of slower first navigation into a dependency. disableSolutionSearching stops editors from searching upwards for a solution tsconfig.json, useful when a root solution file lists hundreds of projects. Both are editor-only options and do not affect tsc -b.

A related symptom is "the editor shows errors CI does not" or the reverse. Editors check referenced projects' source for navigation, while tsc -b reads their declarations; if declarations are stale, the two disagree. Running tsc -b once after pulling changes — or letting a watch process run tsc -b --watch — keeps them aligned.

Tools that manage references for you

Keeping references in sync with package.json dependencies by hand does not scale past a dozen packages. Several tools automate it:

  • Nx's TypeScript plugin can maintain references from the project graph and infer typecheck targets.
  • workspaces-to-typescript-project-references and similar small CLIs rewrite every package's references from its workspace dependencies; run them in a postinstall or as a CI check that fails on drift.
  • Custom scripts are straightforward: read each package's dependencies, keep the ones that are workspace packages, and write their relative paths into references.

Whichever you use, add a CI check that regenerates references and fails if the committed files differ. Drift between declared dependencies and references is the root cause of most TS6305 errors.

Watch mode during development

tsc -b --watch keeps the whole reference graph up to date as you edit: change a file in packages/utils, and TypeScript rebuilds that project's declarations and then re-checks every project that references it. For developers working across several packages at once, running it in a terminal alongside dev servers gives continuous type feedback for the entire repository, without waiting for CI. Because it is incremental, a single edit usually re-checks only the projects affected by it.

Two caveats apply. Watching hundreds of projects uses memory and file watchers, so in very large repositories scope it to the application you are working on (tsc -b apps/web --watch), which still rebuilds that application's references. And if a bundler writes JavaScript into the same dist/ folders in its own watch mode, make sure the two tools write different files (declarations versus JavaScript), or they will trigger each other in a loop.

Measuring the improvement

Before and after adopting references, record three numbers: a cold full type-check (tsc -b after deleting all dist/ folders), a warm type-check after changing one leaf file, and the editor's memory use with a typical set of files open. Cold builds often stay similar — every project is still checked once — but warm builds and editor memory usually improve dramatically. If warm builds do not improve, check with tsc -b --verbose why projects are considered out of date; a project whose inputs change on every build (generated files inside include, or build info written somewhere that gets cleaned) will never be skipped.

Security and isolation

References are a correctness mechanism more than a security one, but they help enforce boundaries: a project can only import another project's public types if it references it and the other project exports them. Combine references with exports maps so consumers cannot reach into another package's internals, and with lint rules for undeclared imports, as described in Detecting Undeclared Cross-Package Imports. Keep .tsbuildinfo and dist/ out of version control; committing them causes stale-build confusion and leaks local paths into the repository.

CI/CD integration

name: typecheck
on: [pull_request]
jobs:
  typecheck:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: pnpm/action-setup@v4
      - uses: actions/setup-node@v4
        with: { node-version-file: .nvmrc, cache: pnpm }
      - run: pnpm install --frozen-lockfile
      # Restore previous build info and declarations so unchanged projects are skipped
      - uses: actions/cache@v4
        with:
          path: |
            packages/*/dist
            apps/*/dist
          key: tsc-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml', '**/tsconfig*.json') }}-${{ github.sha }}
          restore-keys: tsc-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml', '**/tsconfig*.json') }}-
      - run: pnpm exec tsc -b --verbose

Step by step: install dependencies; restore previous dist/ folders (which contain .tsbuildinfo and declarations) keyed on the lockfile and TypeScript configuration, so a configuration change forces a clean build; run tsc -b, which skips projects whose sources and references have not changed. The key includes the configuration hash because stale build info across configuration changes is a common source of confusing results.

Pitfalls

Mistake Impact Remediation
References out of sync with dependencies TS6305 or wrong build order Generate references from package.json
composite project with files outside include TS6307 "file is not listed" Include every source file or move it
Running tsc instead of tsc -b Referenced outputs not built; TS6305 Use build mode for referenced projects
Committing dist/ or .tsbuildinfo Stale builds, noisy diffs Git-ignore build output
Both references and cross-package paths Double checking, confusing resolution Remove paths aliases between packages

Guides in this topic

Every guide below solves one concrete task or error within TypeScript Project References in Monorepos. Start with the one whose symptom matches what you are seeing:

Frequently Asked Questions

Do I need project references if I use Turborepo or Nx? Not strictly — a task runner can order per-package tsc --noEmit calls and cache them. References still remove repeated checking of dependency source and speed up editors, so many large repositories use both.

Do project references work with bundler resolution? Yes. References are independent of moduleResolution; they control which files the compiler reads for referenced projects.

Can applications that emit no JavaScript be composite? Yes. Set emitDeclarationOnly or use noEmit only in leaf projects that nothing references. Referenced projects must emit declarations.

What is the TypeScript 5.x --build improvement for noEmit? Recent TypeScript versions allow tsc -b with noEmit in more configurations, making type-check-only builds of leaf projects simpler. Check your TypeScript version's release notes before relying on it.

Should tests be part of composite projects? Usually not the library project itself, because test files would be emitted as declarations and become part of the package's type surface. Give tests their own tsconfig.test.json that references the library project, or let the test runner type-check them.

Why is my first tsc -b run no faster than before? Because a cold build still checks every project once. The gains come from warm builds, where unchanged projects are skipped, and from not re-checking dependency source inside every consumer. Measure warm builds after a one-file change to see the difference.

Can I reference a project outside the workspace packages? Yes — any folder with a composite tsconfig.json can be referenced, such as a shared types/ folder. Treat it like a package: give it an owner, and prefer turning it into a real workspace package so the package graph and the reference graph stay identical.

Related

Monorepo Architecture & Orchestration