Skip to main content

Phantom Dependencies: What pnpm Strict Mode Reveals

Tangled wires and messy cables
Apr 8, 20263 min readpnpm, Monorepo, Nx, Node.js

The Accidental Dependency

Yarn Classic hoists every package up to the root node_modules, which 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 down in package.json.

I migrated this Nx monorepo from Yarn Classic to pnpm v10, and the command itself was uneventful: corepack enable pnpm && pnpm import converts yarn.lock to pnpm-lock.yaml. What happened right after was an audit I hadn't signed up for.

The First Failure

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

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

Yarn Classic resolved it through hoisting; pnpm flatly refused. The fix was to say it out loud:

// apps/root/package.json — after
{
  "dependencies": {
    "@danieljoffe/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, a library that 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 instead of at the root.

pnpm gives you an escape hatch for exactly this: .npmrc hoist patterns.

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

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

Subpath Exports

The shared-ui library had a single barrel export. Consumers could import { Button } from '@danieljoffe/shared-ui', but they couldn't reach individual files or style constants. Under Yarn Classic, deep imports like @danieljoffe/shared-ui/styles/formStyles resolved through plain file system traversal; pnpm wants 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 started out as *.tsx, which couldn't match the .ts files in the styles/ directory, so I added explicit ./styles/* and ./types entries to cover both extensions. Without pnpm's strictness, that mismatch would have turned up only after some consumer tried tree-shaking and got back an empty module.

What I Gained

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

  • 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 just means your mistakes happen to work for now. pnpm's strict mode is really a linter for your package.json: it catches what you forgot to declare before production gets the chance to.