Structuring TypeScript Monorepos Cleanly with pnpm Workspaces
AI generated
type
TypeScript
TypeScript Monorepos with pnpm
Structuring workspaces cleanly, without phantom dependencies

pnpm workspaces organize multiple TypeScript packages in one repository without the dependency duplication of npm or the generous hoisting of Yarn Classic. Anyone setting up a new monorepo or migrating an existing one benefits from a stricter, more predictable dependency tree.

10 min read pnpm Monorepo

1. Why pnpm workspaces for TypeScript monorepos

pnpm stores every installed package exactly once in a global, content-addressed store and links it into each package's node_modules folder via hard links. That saves disk space and speeds up installs noticeably, especially with many packages sharing overlapping dependencies.

More important for TypeScript monorepos is the strict node_modules structure: by default a package only sees the dependencies it actually declares itself, not the transitively installed packages of a neighboring package.

This prevents so-called phantom dependencies, where code compiles and runs because a package happens to be reachable through another package in the tree, but breaks the moment that incidental availability changes.

2. pnpm-workspace.yaml and package structure

The pnpm-workspace.yaml file at the repository root defines which directories count as workspace packages, usually via glob patterns like packages/* and apps/*. Every matched directory with its own package.json becomes an independent package in the workspace.

A typical structure separates reusable libraries under packages/ from standalone applications under apps/, so a UI component package, a utility package, and a web application can coexist in the same repository while being versioned and tested independently.

Each package gets its own tsconfig.json, package.json, and, where needed, its own build scripts, while shared tooling configuration such as ESLint or Prettier lives centrally at the root and is referenced by every package.


# pnpm-workspace.yaml
packages:
  - "packages/*"
  - "apps/*"
  - "!**/test/**"

3. Cross-package references with TypeScript project references

For a package to correctly resolve types from another workspace package while building incrementally, instead of rebuilding the entire monorepo on every change, TypeScript project references via references in tsconfig.json are the mechanism to reach for.

Every referenced package must set composite: true in its compilerOptions so TypeScript emits declaration files and a build-info cache that dependent packages can reuse for their own type checking.

The tsc --build command, or tsc -b for short, automatically respects the dependency order between referenced projects and only rebuilds the packages whose source or dependencies changed since the last run.


// packages/api-client/tsconfig.json
{
  "compilerOptions": {
    "composite": true,
    "declaration": true,
    "outDir": "dist"
  },
  "references": [{ "path": "../shared-types" }]
}

4. A shared tsconfig base via extends

Instead of maintaining compilerOptions separately in every package, a central tsconfig.base.json at the root defines shared settings such as strict, target, or moduleResolution, which every package picks up via extends.

Package-specific deviations, such as different lib settings for a Node.js backend package versus a React frontend package, get overridden locally in that package's own tsconfig.json, without touching the shared base.

This structure noticeably reduces configuration drift between packages: a change to the base, such as a stricter compiler option, applies to the whole monorepo instantly instead of needing to be replicated package by package.

5. Dependency management with the workspace protocol

Instead of specifying a version number from the npm registry, "@acme/shared-types": "workspace:*" in package.json directly references the local package in the same repository, independent of its published version.

When publishing, pnpm automatically replaces the workspace: protocol with the actual version number of the referenced package, so published packages ship with correct, resolvable dependencies without developers having to maintain that by hand.

Variants like workspace:^ or workspace:~ control which semver range prefix gets used at publish time, while locally during development the current working copy is always used, with no reinstall needed after every change.


// apps/web/package.json
{
  "dependencies": {
    "@acme/shared-types": "workspace:*",
    "@acme/ui": "workspace:^"
  }
}

6. Build orchestration: tsc -b, Turborepo, or Nx

For smaller monorepos, running tsc --build on the root project with all referenced packages is enough to build incrementally in the correct order. TypeScript's built-in build cache is often entirely sufficient here.

Once build times or the number of parallel scripts grow, tools like Turborepo or Nx take over orchestration: they automatically detect the dependency graph between workspace packages from package.json and cache build outputs remotely, team-wide, not just locally.

Both approaches combine well: tsc -b handles the actual type checking and compilation per package, while Turborepo or Nx just manage ordering, parallelization, and caching across every script in the monorepo.

7. Publishing and versioning with changesets

For monorepos that actually publish individual packages to npm, Changesets has become the de facto standard: every change gets a small markdown file describing which packages are affected and whether it's a patch, minor, or major release.

At release time, Changesets collects every pending changeset file, computes the new version numbers while respecting the workspace: protocol, and automatically updates every affected package.json along with the CHANGELOG.

This works reliably with pnpm specifically because the strict dependency resolution ensures a published package really only contains the dependencies it declared, instead of accidentally benefiting from a hoisted neighbor.

8. CI caching strategies for fast pipelines

pnpm already provides an efficient foundation for CI caching via pnpm fetch and its content-addressed store: the global store can be cached between pipeline runs, so repeated installs get served almost entirely from local cache.

For the actual build and test execution, additional task-level caching pays off, for example via Turborepo remote caching, which reuses build outputs based on a hash of source and dependencies whenever nothing changed in a package.

In practice, combining pnpm store caching with task-level caching often cuts CI runtime in growing monorepos by more than half, since unchanged packages are neither reinstalled nor rebuilt nor retested.

9. Common pitfalls: hoisting, peer dependencies, circular references

pnpm's strict node_modules structure reliably surfaces phantom dependencies, but it occasionally trips up packages that internally rely on incorrectly assuming transitively available dependencies instead of declaring them explicitly.

Peer dependencies require, in a strict setup, that every package expecting one actually finds a compatible version somewhere in the workspace. If it's missing, pnpm reports that explicitly as a warning instead of silently papering over it through hoisting.

Circular dependencies between workspace packages, for instance when package A imports package B and vice versa, are flagged as an error by tsc --build and must be resolved before the first successful build, usually by extracting the shared types into a third, common package.

Aspect pnpm Workspaces npm Workspaces Yarn Workspaces
node_modules structure Strict, no phantom dependencies Flat and hoisted, prone to phantom deps Flat and hoisted (Classic), strict (Berry PnP)
Disk usage Low, via a global store with hard links High, duplicates possible per project Medium to low, depending on the mode
workspace protocol workspace:* natively supported Supported since npm 7 Supported since Yarn Berry
Install speed Very fast thanks to caching and hard links Medium Fast with Plug'n'Play, otherwise medium
Monorepo tooling integration Excellent with Turborepo, Nx, Changesets Good, somewhat more manual configuration Good, PnP can cause compatibility issues

Mironsoft

TypeScript migration, type safety, and team onboarding

A JavaScript codebase without type safety, but no time for a full migration?

We migrate existing JavaScript projects to TypeScript step by step, set up strict compiler settings cleanly, and bring teams to the same type-safety level with code reviews and style guides.

Migration Roadmap

Plan and execute a gradual JS-to-TS migration without big-bang risk.

Strict Mode Rollout

Set up tsconfig.json, ESLint rules, and CI checks for lasting type safety.

Team Onboarding

Bring developers up to speed on TypeScript best practices with workshops and reviews.

10. Summary

pnpm Workspaces

No phantom deps

Strict node_modules structure per package

workspace: protocol

Local reference instead of registry version

Project references

tsc -b for incremental builds

Less disk space

One global store instead of duplicates

11. FAQ: pnpm Workspaces

1What fundamentally distinguishes pnpm workspaces from npm workspaces?
pnpm keeps every package in a global store and links it strictly per package, while npm hoists dependencies flat into a shared node_modules, which encourages phantom dependencies.
2What is a phantom dependency and why does pnpm prevent it?
A phantom dependency is a package that works in code despite never being declared, because it happens to be reachable through hoisting. pnpm's strict structure makes that kind of access impossible.
3What does workspace:* mean in package.json?
It references another package in the same workspace regardless of its published version. When publishing, pnpm automatically replaces it with the actual version number.
4Do I need TypeScript project references for a pnpm monorepo?
They are not strictly required, but they enable incremental, order-correct builds via tsc -b and prevent the entire monorepo from needing a full rebuild on every change.
5How do I share a common tsconfig across multiple packages?
Via a central tsconfig.base.json at the root that every package includes through extends, overriding only package-specific settings locally.
6Do I also need Turborepo or Nx with pnpm workspaces?
For small monorepos, tsc -b is often enough. As package count and build time grow, Turborepo or Nx pay off for task orchestration and remote caching.
7How does publishing individual packages from a pnpm monorepo work?
Changesets has become the standard: every change gets a markdown file, and at release time version numbers and CHANGELOG entries are generated from it automatically.
8How do I cache pnpm installs efficiently in a CI pipeline?
The global pnpm store can be cached between pipeline runs, so repeated installs are served largely from local cache without a fresh download from the registry.
9What happens with circular dependencies between workspace packages?
tsc --build flags circular references as an error. The usual fix is extracting the shared types into a third, independent package that both original packages import.
10Are peer dependencies a problem in pnpm workspaces?
They require an explicitly compatible version somewhere in the workspace. If it is missing, pnpm reports a warning instead of silently ignoring it the way flat hoisting would.