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.
Table of Contents
- 1. Why pnpm workspaces for TypeScript monorepos
- 2. pnpm-workspace.yaml and package structure
- 3. Cross-package references with TypeScript project references
- 4. A shared tsconfig base via extends
- 5. Dependency management with the workspace protocol
- 6. Build orchestration: tsc -b, Turborepo, or Nx
- 7. Publishing and versioning with changesets
- 8. CI caching strategies for fast pipelines
- 9. Common pitfalls: hoisting, peer dependencies, circular references
- 10. Summary
- 11. FAQ
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