tsconfig Project References for Monorepos
Wire TypeScript project references across a monorepo so packages build in dependency order, share types, and emit declarations without circular errors.
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
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:
coreimports a helper fromutilswhileutilsimports fromcore, so thereferencesgraph — 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 bothcoreandutilsreference, 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
rootDirinference withcomposite.composite: truerequires TypeScript to compute a stablerootDir; if yourincludepattern spanssrc/and a top-levelindex.ts, setrootDirexplicitly or the emitteddist/structure will not match whatexportsexpects.- Editor vs CLI drift. VS Code’s TypeScript server resolves project references live and can show green squiggles as clean while a stale
.tsbuildinfoon disk causes the CLI to report an error. Runtsc --build --forceif the two disagree. .tsbuildinfoin CI caches. If your CI pipeline cachesnode_modulesbut not each package’s.tsbuildinfofile, every CI run performs a full rebuild even though local machines build incrementally. Cachepackages/*/dist/*.tsbuildinfoexplicitly.- Solution-style root config committed to
.gitignoreby accident. Some.gitignoretemplates excludetsconfig.jsonvariants matchingtsconfig.*.json; verify the root solution file is tracked. - Declaration-only packages. A package with no runtime code (pure types) still needs
composite: trueanddeclaration: trueif anything in the graph references it — omitnoEmit, which is incompatible withcomposite.
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.
Related
- Composite Builds for Multi-Package Repos — the caching and invalidation behavior that
composite: trueunlocks once references are wired correctly. - Publishing Packages from a pnpm Workspace — what happens to these declaration files and the
workspace:protocol at publish time. - Path Mapping and Module Resolution Strategies — why
pathsaliases and project references solve overlapping problems differently and should not be combined for the same package pair.