Skip to main content

Phantom Dependencies: What pnpm Strict Mode Reveals About Your Monorepo

Apr 8, 20262 min readpnpm, Monorepo, Nx, DevOps

The Accidental Dependency

Yarn Classic hoists every package to the root node_modules. That means your code can import anything any package depends on, whether you declared it or not. You don't notice until you switch to a package manager that enforces what you actually wrote in package.json.

We migrated this Nx monorepo from Yarn Classic to pnpm v10. The command itself was uneventful: corepack enable pnpm && pnpm import converts yarn.lock to pnpm-lock.yaml. What happened next was an audit we didn't plan for.

The First Failure

pnpm uses a symlinked node_modules structure. Each package can only access its declared dependencies. On the first pnpm install, the root app failed to resolve @danieljoffe.com/shared-ui. It had been importing the workspace library for months without declaring it:

// apps/root/package.json — before
{
  "dependencies": {
    // shared-ui is missing entirely
  }
}

Yarn Classic resolved it through hoisting. pnpm refused. The fix was explicit:

// apps/root/package.json — after
{
  "dependencies": {
    "@danieljoffe.com/shared-ui": "workspace:*"
  }
}

One line. The kind of line that should have been there from the start.

Sentry and the Hoisting Escape Hatch

The second failure was subtler. Sentry's Node.js SDK intercepts module loading at runtime using import-in-the-middle. That library patches require and import calls to inject tracing hooks before your code runs. Under pnpm's strict isolation, import-in-the-middle can't find the modules it needs to patch because they live in nested .pnpm directories, not at the root.

pnpm provides an escape hatch: .npmrc hoist patterns.

# .npmrc
public-hoist-pattern[]=@sentry/*
public-hoist-pattern[]=*import-in-the-middle*

These patterns tell pnpm to hoist matching packages to the root node_modules, restoring the flat structure Sentry expects. It's a scoped exception, not a global override. Every other package stays isolated.

Subpath Exports

The shared-ui library had a single barrel export. Consumers could import { Button } from '@danieljoffe.com/shared-ui', but they couldn't import individual files or style constants. Under Yarn Classic, deep imports like @danieljoffe.com/shared-ui/styles/formStyles resolved through file system traversal. pnpm requires explicit subpath exports in package.json:

{
  "exports": {
    ".": { "default": "./dist/index.js" },
    "./*": { "default": "./src/lib/*.tsx" },
    "./styles/*": { "default": "./src/lib/styles/*.ts" },
    "./types": { "default": "./src/lib/types.ts" }
  }
}

The ./* wildcard initially used *.tsx, which couldn't match .ts files in the styles/ directory. We added explicit ./styles/* and ./types entries to cover both extensions. Without pnpm's strictness, this mismatch would have surfaced only after a consumer tried tree-shaking and got an empty module.

What We Gained

The migration touched 4 CI workflow files, 1 Dockerfile, and the root package.json (converting Yarn resolutions to pnpm pnpm.overrides). In return:

  • Strict dependency resolution caught one phantom dependency and one incomplete export map
  • Content-addressable storage means packages are stored once on disk, symlinked into each project
  • CI install times dropped from Yarn Classic's flat copy to pnpm's linked approach
  • pnpm-workspace.yaml replaced the implicit workspaces field in package.json with an explicit workspace declaration

The Principle

Loose dependency resolution doesn't mean your dependency graph is correct. It means your mistakes happen to work. pnpm's strict mode is a linter for your package.json: it catches what you forgot to declare before production does.