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

What Changes When One Package Becomes Several

A workspace is not a bigger version of a single package; it introduces three new failure modes that do not exist when there is only one manifest. Each has a specific symptom and a specific place to fix it.

Failure modes that only exist in a workspace Three columns: a build that compiles packages in the wrong order, a published package that depends on an internal package which was never published, and a dependency range that resolves only because of workspace linking. Three things that cannot go wrong in a single-package repo wrong build order a dependent compiles against yesterday's declarations symptom: type errors that vanish on a second run fix: tsc --build with project references, so the graph decides the order phantom dependency a published package imports an internal one, never published symptom: MODULE_NOT_FOUND for consumers only fix: mark it private, or publish it, or inline it into the dependent's build unrewritten range workspace:^ reaches the registry verbatim symptom: notarget error at install, for everyone fix: publish through a tool that rewrites it, and check the packed manifest

The phantom-dependency case is the one most likely to reach the registry, because everything works locally: the workspace symlinks the internal package into node_modules, so imports resolve and tests pass. Only a consumer installing from the registry discovers that @acme/internal-utils does not exist there. Two defences catch it before publish — marking genuinely internal packages "private": true so they cannot be published by accident, and verifying the packed manifest of every publishable package against what is actually on the registry:

# every dependency of a publishable package must exist publicly
node -e '
  const pkg = require("./package.json");
  const deps = Object.keys(pkg.dependencies ?? {});
  const { execSync } = require("child_process");
  for (const d of deps) {
    try { execSync(`npm view ${d} version`, { stdio: "pipe" }); }
    catch { console.error(`FAIL: ${d} is not published — ${pkg.name} cannot be installed`); process.exit(1); }
  }
  console.log(`OK: all ${deps.length} dependencies are published`);
'

Verifying the Packed Manifest, Not the Source One

The package.json in your repository and the one inside the tarball are different files in a workspace. workspace:^ becomes a real range, publishConfig fields are merged in, and some tools rewrite main/exports paths for a dist-relative layout. Every workspace publishing bug ultimately comes down to a difference between those two files, so the highest-value check is simply to look at the packed one:

npm pack --silent >/dev/null && tar -xzOf *.tgz package/package.json | head -30
{
  "name": "@acme/utils",
  "version": "1.3.0",
  "dependencies": {
    "@acme/core": "^2.1.0"
  },
  "exports": {
    ".": { "types": "./dist/index.d.ts", "import": "./dist/index.mjs", "require": "./dist/index.cjs" }
  }
}

Seeing "@acme/core": "^2.1.0" rather than "workspace:^" is the single confirmation that the rewrite happened. Automating that assertion is three lines and removes the most common workspace publishing incident entirely:

tar -xzOf *.tgz package/package.json | grep -q '"workspace:' \
  && { echo "FAIL: unrewritten workspace protocol in the tarball"; exit 1; } \
  || echo "OK: all internal ranges rewritten"

There is a second, subtler difference worth checking in a workspace: the repository.directory field. Without it, every package in the workspace points readers at the repository root, and provenance consumers comparing the attested source path against the manifest find a mismatch between what the attestation records (the workspace path) and what the manifest claims (the root).

Releasing a Graph Instead of a Package

The order in which packages publish matters because npm resolves dependencies at install time, not at publish time. Publishing @acme/[email protected], which depends on @acme/core@^2.0.0, before @acme/[email protected] exists creates a window — often only seconds, but real — in which anyone installing @acme/app gets a resolution failure.

A topological publish is therefore not a nicety. The package manager can compute the order for you, and the practical form is a filtered, dependency-ordered command rather than a hand-maintained list:

# pnpm publishes in topological order across the workspace
pnpm -r publish --access public --no-git-checks
Scope: 3 of 3 workspace projects
packages/core     | Publishing @acme/[email protected]
packages/utils    | Publishing @acme/[email protected]
packages/app      | Publishing @acme/[email protected]

Two refinements make this robust in CI. First, make the publish idempotent per package — check the registry for each version before attempting it — so a partial failure can be retried without the already-published packages failing the whole run. Second, publish everything under a temporary dist-tag and promote the tags only after the entire graph succeeded; that way a failure halfway through never leaves consumers resolving a half-released set through latest. The tag mechanics are the same as for a single package, and are covered in Managing Prerelease and Dist-Tags on npm.


Sharing Configuration Without Coupling Packages

Every package in a workspace needs a tsconfig.json, a build script, and a set of lint rules, and copying them produces drift within weeks. The pattern that scales is a base configuration each package extends, with only genuinely per-package values left local.

{
  "compilerOptions": {
    "target": "ES2022",
    "module": "NodeNext",
    "moduleResolution": "NodeNext",
    "strict": true,
    "declaration": true,
    "declarationMap": true,
    "composite": true,
    "skipLibCheck": true
  }
}
{
  "extends": "../../tsconfig.base.json",
  "compilerOptions": { "outDir": "./dist", "rootDir": "./src" },
  "references": [{ "path": "../core" }],
  "include": ["src/**/*.ts"]
}

Only three things stay in the package-level file: where output goes, where source lives, and which siblings it references. Everything else is inherited, so raising the compilation target or turning on a new strictness flag is a one-file change reviewed once.

The same logic applies to build scripts, and here the leverage is larger. A shared build script published as an internal (private) package — or simply a script at the workspace root invoked with the package directory as an argument — means a change to the build pipeline lands everywhere at once. The alternative, twenty near-identical tsup.config.ts files, guarantees that at least one package quietly stops emitting declarations and nobody notices until a consumer reports missing types.

Two caveats keep shared configuration from becoming its own problem. First, extends resolves paths relative to the extending file for include/exclude but relative to the base file for some other options, which is why base configs should avoid path-bearing options entirely — no outDir, no rootDir, no paths. Second, a package that genuinely needs to differ should override explicitly rather than have the base grow a conditional; a base config with special cases for two packages is harder to reason about than two packages with three extra lines each.

Where the shared configuration touches published output — target, module, declaration settings — treat a change to it as a change to every package’s public artefacts. Raising target from ES2020 to ES2022 across a workspace changes the syntax every consumer receives, and although it is usually safe, it is a change worth its own release note rather than one buried in a chore commit. The same goes for a change to the shared build script: it alters the artefacts of every package in the workspace simultaneously, which is exactly the kind of blast radius that deserves a deliberate release rather than a routine merge. Running the full validation chain across every package after such a change, rather than only the package that was edited, is the cheapest way to find out which of the twenty packages had been quietly depending on the old behaviour. The compiler-option trade-offs themselves are covered in Optimizing tsconfig.json for Library Distribution.


Source Manifest Versus Packed Manifest

The two files differ in exactly three ways, and knowing which three makes the verification step quick to read.

What changes between the source and packed manifest Internal workspace ranges become real semver ranges, publishConfig entries are merged into the top level, and some tools rewrite entry paths to be relative to the distribution directory. Only three things change on the way into the tarball workspace ranges "workspace:^" → "^2.1.0" if this one fails, nobody can install the package publishConfig merge access, registry, provenance decides where it goes and who can see it entry path rewrites tool-specific, not universal check it, because exports paths must still resolve

Extracting the packed manifest takes one command, and it answers all three questions at once — which is why it belongs in the release script rather than in a maintainer’s memory.


Guides in This Section



Back to TypeScript Configuration & Build Tooling