Add a second internal package to a monorepo and the compiler often stops midway with:

error TS6306: Referenced project '../core/tsconfig.json' must have setting "composite": true.

or, once that is fixed, a slower failure mode where tsc --build silently recompiles every package on every run even though only one file changed. Both symptoms trace back to the same cause: project references are not automatic just because packages live in the same workspace — each tsconfig.json must explicitly declare which sibling packages it depends on, and each of those siblings must opt in to being referenced.


Root Cause

TypeScript’s --build mode treats each tsconfig.json with a references array as a node in a build graph, not as a single flat compilation. For tsc to know that packages/app depends on packages/core, the app config must list { "path": "../core" } under references, and the core config must set "composite": true so it emits the .d.ts files and the .tsbuildinfo metadata that downstream projects rely on. Without composite, TypeScript has no guarantee the referenced project emits declarations at all, so it refuses to proceed. This is the same coordination problem covered generally in Monorepo & Workspace Publishing — the workspace manager resolves the package graph at install time, but TypeScript needs its own, separate graph for compilation.


Minimal Reproduction

A two-package workspace where utils imports from core but neither tsconfig.json declares the relationship:

// packages/core/tsconfig.json
{
  "compilerOptions": {
    "outDir": "./dist",
    "declaration": true
  },
  "include": ["src/**/*.ts"]
}
// packages/utils/tsconfig.json — missing "references" and missing composite on core
{
  "compilerOptions": {
    "outDir": "./dist",
    "declaration": true
  },
  "include": ["src/**/*.ts"]
}
// packages/utils/src/index.ts
import { normalize } from '@acme/core'; // resolves via node_modules symlink, not project references

This compiles under a plain tsc invocation inside packages/utils because Node-style resolution finds the symlinked @acme/core package. It breaks the moment you try to run tsc --build from the repository root, or the moment core’s source changes and utils needs to pick up new types without a manual rebuild.


How the Build Graph Resolves

tsc --build reference graph walk tsc --build reads the root tsconfig references, visits packages/core first because nothing depends on it, then utils, then app, checking each project's .tsbuildinfo before deciding whether to recompile. Read root references Build packages/core Check core .tsbuildinfo Build packages/utils Build packages/app

Step-by-Step Fix

Step 1 — Mark every referenced package composite

  // packages/core/tsconfig.json
  {
    "compilerOptions": {
+     "composite": true,
      "outDir": "./dist",
      "declaration": true
    },
    "include": ["src/**/*.ts"]
  }

composite implies declaration: true and forces rootDir to be inferred strictly, so add "rootDir": "./src" explicitly if your include pattern spans more than one top-level folder.

Step 2 — Declare the reference in the dependent package

  // packages/utils/tsconfig.json
  {
    "compilerOptions": {
      "outDir": "./dist",
      "declaration": true
    },
+   "references": [
+     { "path": "../core" }
+   ],
    "include": ["src/**/*.ts"]
  }

The path is a directory or a direct path to a tsconfig.json — both are accepted, but a directory is easier to keep correct when file names change.

Step 3 — Add a root solution config

Create a tsconfig.json at the repository root with "files": [] so it never tries to compile anything itself — it only exists to list every package for tsc --build.

{
  "files": [],
  "references": [
    { "path": "packages/core" },
    { "path": "packages/utils" },
    { "path": "packages/app" }
  ]
}

Step 4 — Build with --build instead of a plain invocation

tsc --build --verbose

Expected output:

Project 'packages/core/tsconfig.json' is out of date because output file 'dist/index.js' does not exist
Building project '/repo/packages/core/tsconfig.json'...
Project 'packages/utils/tsconfig.json' is out of date because its dependency 'packages/core' is newer
Building project '/repo/packages/utils/tsconfig.json'...
Project 'packages/app/tsconfig.json' is out of date...
Building project '/repo/packages/app/tsconfig.json'...

HAZARD PREVENTION

Symptom: error TS6202: Project references may not form a circular graph. Cycle detected: packages/core -> packages/utils -> packages/core.

Root cause: core imports a helper from utils while utils imports from core, so the references graph — which must mirror the actual import graph — cannot be a directed acyclic graph.

Fix: Extract the shared helper into a third package (for example @acme/shared) that both core and utils reference, breaking the cycle. There is no configuration flag that permits circular references; the dependency direction itself must change.


Verification

# Clean build from scratch — proves the reference graph is complete
tsc --build --clean
tsc --build --verbose

# Re-run with no changes — should print nothing and exit 0
tsc --build
echo "exit code: $?"

Expected output on the unchanged re-run:

exit code: 0

No log lines at all on the second run confirms every project’s .tsbuildinfo cache is valid and being honored.


Edge Cases / Gotchas

  • rootDir inference with composite. composite: true requires TypeScript to compute a stable rootDir; if your include pattern spans src/ and a top-level index.ts, set rootDir explicitly or the emitted dist/ structure will not match what exports expects.
  • Editor vs CLI drift. VS Code’s TypeScript server resolves project references live and can show green squiggles as clean while a stale .tsbuildinfo on disk causes the CLI to report an error. Run tsc --build --force if the two disagree.
  • .tsbuildinfo in CI caches. If your CI pipeline caches node_modules but not each package’s .tsbuildinfo file, every CI run performs a full rebuild even though local machines build incrementally. Cache packages/*/dist/*.tsbuildinfo explicitly.
  • Solution-style root config committed to .gitignore by accident. Some .gitignore templates exclude tsconfig.json variants matching tsconfig.*.json; verify the root solution file is tracked.
  • Declaration-only packages. A package with no runtime code (pure types) still needs composite: true and declaration: true if anything in the graph references it — omit noEmit, which is incompatible with composite.

Frequently Asked Questions

Do I need composite: true in every package, or only the ones being referenced?

Every package that appears as a target of a references entry anywhere in the graph must set composite: true, including packages that are themselves only consumers and never referenced by anyone else. It is simplest to set composite: true in every package’s tsconfig.json from the start rather than adding it reactively each time a new reference is introduced.

Why does tsc --build rebuild a package that has no source changes?

The most common cause is a missing or stale .tsbuildinfo file, often because it was excluded from a Docker layer cache or CI cache key. Confirm the .tsbuildinfo path in tsBuildInfoFile is included in your CI cache configuration, and check that outDir does not overlap between packages, which corrupts the incremental snapshot.

Can two packages reference each other in a monorepo?

No. TypeScript project references must form a directed acyclic graph. If package A references package B and package B references package A, tsc --build reports a circular reference error immediately and refuses to build either project. Circular imports at the source level almost always indicate the shared code should move into a third package that both depend on.

Does moduleResolution matter for project references specifically?

Yes. Project references resolve fine under any moduleResolution setting during a build, but consumers importing the published package still need moduleResolution set to nodenext or bundler to read the exports field correctly at runtime. The references array only affects how tsc finds source and declaration files during compilation, not how Node.js or a bundler resolves the published output.

Do I still need paths aliases if I am using project references?

No, and mixing the two causes confusing double-resolution. Project references let TypeScript find a sibling package by its own name once it is installed as a workspace dependency, so a paths alias pointing at the same directory is redundant and can mask a references misconfiguration by silently falling back to path-based resolution.



↑ Back to Monorepo & Workspace Publishing