A monorepo that ships more than one npm package only stays correct if the build order matches the dependency graph and every internal reference resolves the same way for local development and for a consumer who installs from the registry. This guide covers wiring TypeScript project references across a pnpm or npm workspace, turning on composite builds so tsc --build compiles packages incrementally in the right order, and publishing each package with the workspace: protocol correctly rewritten. It assumes familiarity with the compiler options covered in Optimizing tsconfig.json for Library Distribution.


Monorepo build order The workspace root configuration feeds three packages that must build in order: acme-core has no internal dependencies, acme-utils depends on core, and acme-app depends on both, so tsc --build compiles them left to right. Workspace build order workspace root pnpm-workspace.yaml @acme/core no internal deps @acme/utils depends on core @acme/app depends on both

Prerequisites


Canonical Configuration Block

The root workspace manifest and a package-level tsconfig.json are the two files every package in the repository depends on. Get these right before adding project references.

# pnpm-workspace.yaml — at the repository root
packages:
  - "packages/*"
{
  "name": "@acme/utils",
  "version": "1.3.0",
  "dependencies": {
    // The workspace: protocol pins to the local sibling package during
    // development and is rewritten to a real semver range on publish.
    "@acme/core": "workspace:^"
  }
}
{
  "compilerOptions": {
    "composite": true,
    "declaration": true,
    "declarationMap": true,
    "module": "NodeNext",
    "moduleResolution": "NodeNext",
    "outDir": "./dist",
    "rootDir": "./src"
  },
  "references": [
    { "path": "../core" }
  ],
  "include": ["src/**/*.ts"]
}

composite: true is what allows tsc --build to treat this package as a dependency another package can reference; without it, a references entry pointing at this folder throws Referenced project must have setting "composite": true.


Step-by-Step Implementation

Step 1 — Define the workspace packages

List every package directory in the workspace manifest, then give each one a scoped name and declare internal dependencies with the workspace: protocol so the package manager symlinks siblings instead of fetching them from the registry.

{
  "name": "@acme/app",
  "dependencies": {
    "@acme/core": "workspace:^",
    "@acme/utils": "workspace:^"
  }
}

Run pnpm install at the root. Expected result: node_modules/@acme/core inside packages/app is a symlink back to packages/core, so edits to core are visible to app without a publish step.

HAZARD PREVENTION

Symptom: pnpm install succeeds, but @acme/utils resolves an old published version of @acme/core instead of the local sibling.

Root cause: The dependency was declared with a plain semver range ("^1.0.0") instead of workspace:^, so pnpm treats it as an external registry dependency even though a local package of the same name exists.

Fix: Change every internal dependency to workspace:^ (or workspace:* for an exact pin) and reinstall.

Step 2 — Add TypeScript project references

Add a references array to each package’s tsconfig.json pointing at the relative path of every internal package it imports from, and mark every referenced package "composite": true.

{
  "compilerOptions": { "composite": true, "declaration": true },
  "references": [
    { "path": "../core" },
    { "path": "../utils" }
  ]
}

Expected result: opening packages/app/src/index.ts in an editor resolves types from @acme/core and @acme/utils directly from their .ts source during development, and from their emitted .d.ts files once built — no manual path aliasing required. Full alias-avoidance is covered in tsconfig Project References for Monorepos.

Step 3 — Enable composite builds with tsc --build

Add a root tsconfig.json that references every package, then run tsc --build once from the repository root instead of invoking tsc separately inside each package.

{
  "files": [],
  "references": [
    { "path": "packages/core" },
    { "path": "packages/utils" },
    { "path": "packages/app" }
  ]
}
tsc --build --verbose

Expected output on a clean checkout:

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'...

Running tsc --build again with no source changes prints nothing and exits 0 — the incremental .tsbuildinfo cache short-circuits work that is already up to date. See Composite Builds for Multi-Package Repos for cache invalidation details.

HAZARD PREVENTION

Symptom: tsc --build reports Project 'packages/app' is not listed within the file list of project 'packages/core' or silently rebuilds everything on every run.

Root cause: A .tsbuildinfo file was committed to source control, or outDir overlaps between packages, corrupting the incremental cache.

Fix: Add *.tsbuildinfo to .gitignore and give every package its own non-overlapping outDir.

Step 4 — Establish publish order and versioning

Publish leaf packages (no internal dependents) before packages that depend on them, so the registry never serves a package whose declared dependency version does not yet exist.

# Publish in dependency order — core has no internal deps, so it goes first
pnpm --filter "@acme/core" publish --access public
pnpm --filter "@acme/utils" publish --access public
pnpm --filter "@acme/app" publish --access public

pnpm publish automatically rewrites any workspace:^ range in the packed package.json to the sibling package’s current published version, so consumers never see the workspace: protocol string. Full mechanics of this rewrite, plus the npm-workspaces equivalent, are covered in Publishing Packages from a pnpm Workspace. For automating this ordering and the version bump itself, pair it with Versioning and Changelog Automation.

HAZARD PREVENTION

Symptom: A consumer installs @acme/[email protected] and gets npm error notarget No matching version found for @acme/core@workspace:^.

Root cause: The package was packed or published with npm pack/npm publish directly instead of a tool that rewrites the workspace: protocol, so the literal string workspace:^ was published as a dependency range.

Fix: Always publish through pnpm (pnpm publish), Yarn (yarn npm publish), or a release tool such as Changesets that performs the rewrite. Never run bare npm publish inside a package that still has workspace: ranges in its manifest.


Tooling Validation

Run these checks from the workspace root before publishing any package:

# Confirm the build graph compiles cleanly in dependency order
tsc --build --clean && tsc --build

# Confirm no package still has an un-rewritten workspace: range
grep -r "workspace:" packages/*/package.json && echo "FAIL: unresolved workspace ranges" || echo "PASS"

# Validate exports and types per package
pnpm --filter "./packages/*" exec npx publint
pnpm --filter "./packages/*" exec npx attw --pack .

Sample passing output:

Project 'packages/core/tsconfig.json' up to date
Project 'packages/utils/tsconfig.json' up to date
Project 'packages/app/tsconfig.json' up to date
PASS

Compatibility Matrix

Feature pnpm 9+ npm 10+ workspaces Yarn 4+ (Berry) TypeScript 5.4 TypeScript 5.6+
workspace: protocol rewrite on publish Yes No (use "*" + manual bump) Yes N/A N/A
Symlinked local package resolution Yes Yes Yes (or PnP virtual fs) N/A N/A
tsc --build project references N/A N/A N/A Full Full
Composite build .tsbuildinfo caching N/A N/A N/A Full Full, faster invalidation
--filter / recursive per-package scripts Yes (--filter) Yes (-w / --workspace) Yes (workspaces foreach) N/A N/A

Guides in This Section



Back to TypeScript Configuration & Build Tooling