npm, pnpm and Yarn Workspaces: Package Linking in a TypeScript Monorepo
AI generated
<T>
type
TypeScript · Monorepo · Workspaces
npm, pnpm and Yarn Workspaces
Using local package linking correctly in a TypeScript monorepo

Workspaces are the technical foundation without which no TypeScript monorepo works, yet the three major package managers resolve symlinks, hoisting and the workspace protocol differently, and these differences are the cause of many hard to trace import bugs.

16 min read Workspaces · Workspace Protocol · Hoisting npm 10 · pnpm 9 · Yarn Berry

1. What workspaces actually solve in a TypeScript monorepo

Without workspaces, every package in a TypeScript monorepo would have to either be installed via a published npm version of the internal sibling package, or developers would have to manually link packages with npm link and re establish that link on every new checkout. Both variants are impractical in practice, because every change to a shared package would first have to be published or manually reapplied before it becomes visible in another package.

Workspaces solve this problem by having the package manager install all packages of a TypeScript monorepo in a single node_modules tree and automatically resolving internal dependencies as symlinks to the local package directories instead of downloading them from the registry. A change in one package is therefore immediately visible in every other package that imports it, with no publish step and no manual linking. This immediate visibility is exactly what makes a TypeScript monorepo practically usable.

Technically, every package manager creates a symbolic link in the node_modules directory for each internal package during installation, pointing to the actual source folder of the package, for example node_modules/@myorg/shared-utils -> ../../packages/shared-utils. When another package imports @myorg/shared-utils, Node.js follows the symlink and loads the files directly from the source folder, not from a separate copy.

This mechanism has an important consequence for a TypeScript monorepo: without a running build process that compiles the TypeScript source files into JavaScript, the symlink correctly points to the source folder, but the dist output of the imported package may not exist yet or may be outdated. A common problem in freshly cloned repositories is therefore that imports fail because all symlinks are correctly set, but not a single package has been built yet.


# Check the symlink structure after installation
ls -la node_modules/@myorg/

# Example output:
# shared-utils -> ../../packages/shared-utils
# contracts -> ../../packages/contracts

# After a fresh checkout: install first, then build
npm install
npm run build --workspaces

3. The workspace protocol in package.json

To declare an internal package as a dependency, a normal version number like "1.2.0" is not enough in a TypeScript monorepo, because the package manager would otherwise try to download that version from the npm registry instead of linking the local package. The workspace: protocol, originally introduced by pnpm and now also supported by Yarn, makes this intent explicit: "workspace:*" means always use whatever version of the local package currently exists in the repository.

npm itself does not know the workspace: protocol in the same form, but automatically resolves dependencies within the defined workspace scope locally as long as version ranges are compatible, with no special protocol prefix needed. For a cross platform TypeScript monorepo setup that must stay compatible with multiple package managers, it is therefore worth knowing the exact syntax of the tool actually in use, rather than blindly copying configuration between npm, pnpm and Yarn.


{
  "name": "@myorg/api",
  "dependencies": {
    "@myorg/contracts": "workspace:*",
    "@myorg/shared-utils": "workspace:^1.0.0"
  }
}

4. npm workspaces: simple, but with hoisting side effects

npm workspaces are activated via the workspaces field in the root package.json and need no additional configuration file. Installing all packages happens with a single npm install at the root, which npm internally resolves into a shared, flat node_modules tree. This so called hoisting lifts shared external dependencies, for example the same version of React used in two packages, into the root node_modules directory instead of duplicating them separately in each package.

The downside of this hoisting shows up when a package in a TypeScript monorepo accidentally benefits from a dependency it never actually declares in its own package.json, but which is only available through the hoisting of another package. This so called phantom dependency problem works locally, but breaks as soon as the package is installed in isolation or published, because the actually needed dependency is then missing.


{
  "name": "my-typescript-monorepo",
  "private": true,
  "workspaces": [
    "packages/*",
    "apps/*"
  ]
}

5. pnpm workspaces: strict isolation via the content store

pnpm follows a fundamentally different approach. Instead of hoisting packages, pnpm stores every version of every package exactly once in a global content addressable store and links it via hard links and symlinks into a nested node_modules structure per package. Every package in a TypeScript monorepo therefore sees only the dependencies it declares itself in its own package.json, and no hoisted dependencies from other packages.

This strict isolation reliably surfaces phantom dependencies, because a missing but actually needed import immediately fails with an error, instead of accidentally working through a hoisted path. The price is a certain incompatibility with older Node.js tools that assume a flat node_modules structure, which is why pnpm also offers a shamefully-hoist option to fall back to classic hoisting in individual cases.


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

6. Yarn workspaces: Plug'n'Play as a special path

Yarn supports both the classic node_modules mode, which performs hoisting similar to npm, and the more modern Plug'n'Play mode, in which no node_modules directory is created at all. Instead, Yarn generates a single .pnp.cjs file that maps package names to zip archives in the .yarn/cache directory, and Node.js resolves imports through a special resolver instead of following file system symlinks.

For a TypeScript monorepo, Plug'n'Play brings noticeably faster installs and prevents phantom dependencies even more strictly than pnpm, because even accidentally present but undeclared packages simply cannot be resolved from the cache. The downside is lower compatibility with tools that expect direct file system access to node_modules, which is why many teams for new TypeScript projects still choose Yarn's classic nodeLinker: node-modules mode to avoid compatibility problems.

7. tsconfig references and workspace package resolution

Workspaces solve the question of where a package is found at runtime, but do not automatically answer how the TypeScript compiler finds the types of another workspace package while working on one package. Without additional configuration in tsconfig.json, TypeScript relies on already compiled declaration files existing at the node_modules symlink target, which fails in a freshly cloned TypeScript monorepo without a prior build.

The robust solution combines workspaces for runtime module resolution with TypeScript Project References for compile time type checking. Every package explicitly references its internal dependencies via the references array in its tsconfig.json, so the compiler automatically compiles dependent packages as needed instead of relying on outdated or missing dist output.


{
  "compilerOptions": {
    "composite": true,
    "outDir": "./dist",
    "rootDir": "./src"
  },
  "references": [
    { "path": "../shared-utils" },
    { "path": "../contracts" }
  ],
  "include": ["src/**/*.ts"]
}

8. Common mistakes with workspace setups

By far the most common mistake is importing an internal package directly via a relative path, for example import { foo } from "../../shared-utils/src/index", instead of using the package name through the workspace symlink. This works locally at first, but breaks as soon as the directory structure changes or the package needs to be tested independently, because the relative path bypasses the actual package boundary in a TypeScript monorepo.

A second common mistake concerns stale lockfiles: if a new internal package is added to the root package.json but no new install run is performed, the package manager cannot find the new symlink, and imports fail with an error message that misleadingly looks like a typo in the package name. The fix is usually simple but easy to overlook: run install again after every change to the workspace field or to internal dependencies.

9. npm, pnpm and Yarn side by side

All three package managers solve the core problem of local package linking, but differ significantly in isolation, speed and compatibility.

Criterion npm pnpm Yarn (PnP)
Phantom dependencies Possible through hoisting Prevented through isolation Prevented through cache resolver
Disk usage High, many duplicates Low, content store Low, zip cache
Tool compatibility Very high High, rarely needs adjustment Lower in PnP mode
workspace: protocol Implicit, no prefix needed Origin of the protocol Fully supported

For teams prioritizing maximum compatibility with existing tooling, npm remains the lowest risk choice. Teams prioritizing strict isolation against phantom dependencies and lower disk usage in a growing TypeScript monorepo benefit more from pnpm or Yarn in Plug'n'Play mode, but must plan for occasional compatibility issues with older tools in return.

Mironsoft

TypeScript monorepo setup, package management and build infrastructure

Confusing import errors in your workspace setup?

We select the right package manager for your TypeScript monorepo, fix phantom dependency issues, and set up a clean combination of workspaces and project references.

Package manager selection

Choose npm, pnpm or Yarn matched to team size and tooling

Migration

Move existing repositories to workspaces with no downtime

Root cause diagnosis

Systematically track down phantom dependencies and hoisting issues

10. Summary

Workspaces are the technical foundation that makes a TypeScript monorepo practical in the first place, by linking internal packages via symlinks instead of published npm versions. npm relies on simple but phantom dependency prone hoisting, pnpm on strict isolation via a content store, and Yarn in Plug'n'Play mode goes a step further and does away with node_modules entirely.

Regardless of the chosen package manager: the workspace: protocol makes internal dependencies explicit, and combining workspaces for runtime resolution with TypeScript Project References for compile time type checking prevents most of the typical import bugs in a growing TypeScript monorepo.

The choice of package manager is rarely set in stone permanently, a later switch remains possible, but should be planned deliberately with enough testing time, rather than carried out spontaneously in the middle of a sprint.

Workspaces in a TypeScript Monorepo — the key takeaways

Symlink mechanics

Internal packages are linked via symlinks in node_modules, changes are immediately visible without a publish step.

workspace: protocol

Makes it explicit that a dependency should resolve locally instead of from the registry.

Isolation vs. hoisting

pnpm and Yarn PnP prevent phantom dependencies more strictly than npm's classic hoisting.

tsconfig references

Project References complement workspaces with reliable compile time type checking across package boundaries.

11. FAQ: npm, pnpm and Yarn Workspaces

1Difference between npm, pnpm, Yarn workspaces?
npm hoists, pnpm isolates strictly via content store, Yarn additionally offers Plug'n'Play without node_modules.
2What is the workspace: protocol?
A version specifier saying to link an internal package locally instead of loading it from the registry.
3Import broken after checkout?
Usually the build step for internal packages after installation is missing.
4What is a phantom dependency?
An undeclared but hoisted dependency available by accident. Breaks on isolated installation.
5Relative paths instead of package names?
No, always import via package name, relative paths break on structural changes.
6Are workspaces enough for type checking?
Not reliably, Project References in tsconfig.json are additionally needed.
7What is Yarn Plug'n'Play?
A mode without node_modules, resolving via .pnp.cjs and a zip cache. Faster, but less tool compatible.
8Why is pnpm stricter than npm?
Nested node_modules structure, each package sees only its own declared dependencies.
9Mix package managers?
Not recommended, different lockfiles cause conflicts. Commit to one manager per repository.
10Always need to reinstall?
Yes, after every dependency change or workspaces field change, run install again.