| |

Yarn 08 ๐Ÿงถ Plug’n’Play (PnP) Architecture

โฑ๏ธ Estimated read: 11 min | |

Every package manager before Yarn PnP relied on the same fundamental mechanism: placing files into a node_modules directory and letting Node.js walk up the directory tree to find them. Yarn Plug’n’Play discards this model entirely. Instead of copying packages into a folder tree, PnP generates a single loader fileโ€”.pnp.cjsโ€”that contains a complete map of the dependency graph. Node.js consults this map at runtime, resolving imports directly to package archives without ever touching a node_modules directory . The result is faster installs, strict dependency enforcement, and the elimination of ghost dependencies, at the cost of ecosystem compatibility friction.

Key point: PnP replaces the filesystem-based resolution model with a data-driven map. The .pnp.cjs file tells Node.js exactly where every package lives; nothing is hoisted, nothing is implicitly accessible, and nothing is copied into the project directory.


Why PnP exists

The ghost dependency problem. In a traditional node_modules layout, hoisting places transitive dependencies at the top level. Your code can require('lodash') even if lodash is not in your package.json, because it was hoisted from a dependency of a dependency. This works until the hoisting heuristic changesโ€”when you add or remove an unrelated packageโ€”and the import breaks. PnP eliminates this entirely: the loader checks the dependency map, and if a package is not explicitly declared, the import fails immediately with a semantic error explaining exactly what went wrong .

The filesystem overhead problem. A typical node_modules install performs tens of thousands of file copy operations. PnP performs one: it generates the .pnp.cjs file. Packages remain in a global cache as compressed archives (.zip), and the loader resolves them directly from those archives. Install times drop from tens of seconds to a few seconds, and disk usage shrinks because packages are shared across all projects on the machine .

The hoisting imperfection problem. Hoisting is a heuristic. When two packages require incompatible versions of the same dependency, the hoisting algorithm cannot place both at the same level. It duplicates one, or nests it deeper, and the resulting layout depends on the order of installation. PnP’s map has no such limitation: every package version is a distinct entry, and the loader resolves each import to the correct instance without filesystem constraints .

The error message problem. A traditional Node.js error for a missing module is Cannot find module 'not-found'. PnP provides context: “Your application tried to access not-found, but it isn’t declared in your dependencies; this makes the require call ambiguous and unsound.” It names the package that made the request and the parent that should have declared it. This semantic erroring turns a debugging session into a one-line fix .

The trade-off. PnP requires tools to use standard Node.js resolution APIs or integrate with the PnP specification. Some toolsโ€”particularly older bundlers, IDE plugins, and React Nativeโ€”assume node_modules exists and fail under PnP. Yarn has worked with the ecosystem to add native support to Vite, Webpack, Esbuild, ESLint, and TypeScript, but compatibility is not universal .


a. The loader and the dependency map

When yarn install runs with nodeLinker: pnp, it generates a .pnp.cjs file at the project root. This file is a CommonJS module that, when loaded, patches Node.js’s module resolution system. It exports a map of locatorsโ€”unique identifiers combining a package name and a reference (version or workspace path)โ€”to their locations on disk .

// Simplified view of the map inside .pnp.cjs
const dependencyTree = {
  "lodash@npm:4.17.21": {
    packageLocation: "/home/user/.yarn/berry/cache/lodash-npm-4.17.21-...zip/node_modules/lodash/",
    packageDependencies: new Map(),
  },
  "express@npm:4.18.2": {
    packageLocation: "/home/user/.yarn/berry/cache/express-npm-4.18.2-...zip/node_modules/express/",
    packageDependencies: new Map([
      ["accepts", "npm:1.3.8"],
      ["body-parser", "npm:1.20.1"],
      // ...
    ]),
  },
};

The packageLocation points into the global cache. PnP treats .zip files as folders: a path like /cache/lodash.zip/node_modules/lodash/ is resolved by the loader into the archive’s contents without extraction . The packageDependencies map is what enforces strictness. When express calls require('accepts'), the loader checks whether accepts exists in express‘s dependency map. If it does not, the resolution failsโ€”even if accepts is installed somewhere else in the project .

The loader also handles virtual packages. When a package has peer dependencies, PnP creates a virtual instance of that package with a distinct path (__virtual__/<hash>/) so that different peer dependency combinations produce separate instantiations. This ensures that a plugin expecting react@17 does not accidentally receive react@18 from a different part of the tree .


b. Ghost dependency protection and semantic errors

The most immediate consequence of PnP is that undeclared imports fail. In a traditional node_modules setup, a package that forgets to declare a dependency on react will still find react if it was hoisted to the top level. This works locally but breaks in production or when the hoisting layout changes. PnP refuses to make this assumption .

Error: react-cosmos tried to access react, but it isn't declared in its dependencies;
this makes the require call ambiguous and unsound.

This error is not a bug in PnP; it is a bug in the package that omitted the declaration. The Yarn documentation recommends fixing such packages with the packageExtensions setting in .yarnrc.yml, which injects the missing dependency into the package’s metadata without modifying the package itself .

packageExtensions:
  "react-cosmos@*":
    peerDependencies:
      react: "*"
      react-dom: "*"

The protection extends to peer dependencies. If a package declares a peer dependency on @babel/core but the consumer does not provide it, PnP throws a semantic error identifying the broken chain: “awesome-plugin tried to access awesome-core (a peer dependency) but it isn’t provided by its ancestors; ancestor breaking the chain: awesome-template” . This level of diagnostic detail is unavailable in traditional node_modules layouts, where the error is simply a missing module.


c. Ecosystem compatibility and migration

PnP is the default linker in Yarn Berry, but it is not mandatory. The nodeLinker setting in .yarnrc.yml can be set to node-modules or pnpm to fall back to a traditional layout. The PnP path is recommended for new projects, but existing projects with complex toolchains may need the compatibility layer .

For tools that read node_modules directly rather than using Node.js resolution APIs, Yarn provides pnpify. This tool intercepts filesystem calls to node_modules and serves a virtual view backed by the PnP map. However, pnpify is explicitly a transition tool, not a long-term solution. It does not provide the strictness or performance benefits of native PnP .

IDE integration requires additional setup. VS Code, for example, does not natively understand PnP’s module resolution. Yarn provides yarn dlx @yarnpkg/pnpify --sdk vscode, which generates SDK files in .yarn/sdks/ and updates the workspace settings to point TypeScript to the generated SDK rather than the global installation . Without this step, TypeScript will report errors for every import, even though the code runs correctly under Node.js.

The migration path from a traditional node_modules project involves setting the linker, removing the existing node_modules, and running yarn install. The .pnp.cjs file and the cache are generated, and the project is operational if all dependencies are correctly declared. If not, the semantic errors identify the exact packages that need packageExtensions fixes .


Complete Example Session

# ============================================
# PART 1: ENABLE PNP
# ============================================

# Set the linker to PnP in .yarnrc.yml
echo "nodeLinker: pnp" >> .yarnrc.yml

# Remove the existing node_modules
rm -rf node_modules

# Run the install
yarn install
# ============================================
# PART 2: INSPECT THE RESULT
# ============================================

# No node_modules directory exists
ls node_modules
# Error: No such file or directory

# The .pnp.cjs file is the dependency map
ls -la .pnp.cjs
# -rw-r--r-- 1 user user 123456 .pnp.cjs
# ============================================
# PART 3: SEMANTIC ERROR EXAMPLE
# ============================================

# A package imports a dependency it did not declare
node -e "require('some-package')"
# Error: Your application tried to access some-package,
# but it isn't declared in your dependencies;
# this makes the require call ambiguous and unsound.
# ============================================
# PART 4: FIX WITH PACKAGE EXTENSIONS
# ============================================

# Add the missing dependency in .yarnrc.yml
cat >> .yarnrc.yml <<EOF
packageExtensions:
  "broken-package@*":
    dependencies:
      missing-dep: "*"
EOF

yarn install
# ============================================
# PART 5: IDE SETUP
# ============================================

# Generate TypeScript SDK for VS Code
yarn dlx @yarnpkg/pnpify --sdk vscode

# The SDK files are in .yarn/sdks/
ls .yarn/sdks/
# ============================================
# PART 6: RUN A TOOL
# ============================================

# Use yarn run (node_modules/.bin does not exist)
yarn run jest
# ============================================
# PART 7: FALLBACK TO NODE_MODULES
# ============================================

# If PnP is incompatible with a critical tool
yarn config set nodeLinker node-modules
yarn install

The seven parts trace the PnP lifecycle: enabling the linker, inspecting the generated map, encountering a semantic error, fixing it with packageExtensions, configuring the IDE, running a tool, and falling back if necessary.


Quick Reference

PnP vs node_modules

AspectPnPnode_modules
Install output.pnp.cjsFile tree in node_modules/
ResolutionLoader mapFilesystem walk
Ghost dependenciesBlockedPossible
Install speedSecondsTens of seconds
Disk usageShared cachePer-project copies
Ecosystem compatibilityPartialUniversal

Key Files

FilePurpose
.pnp.cjsDependency map and loader
.yarnrc.ymlnodeLinker: pnp setting
.yarn/cache/Compressed package archives
.yarn/sdks/IDE SDK files

Configuration

SettingLocationPurpose
nodeLinker: pnp.yarnrc.ymlEnable PnP
packageExtensions.yarnrc.ymlFix undeclared dependencies
pnpMode: strict.yarnrc.ymlEnforce strict resolution

Best Practices

โœ… Do This:

nodeLinker: pnp
<!-- Enable PnP for new projects -->                 # โœ…
packageExtensions:
  "broken-pkg@*":
    dependencies:
      missing: "*"
<!-- Fix third-party packages with missing deps -->   # โœ…
yarn dlx @yarnpkg/pnpify --sdk vscode
<!-- Generate IDE SDKs for TypeScript -->            # โœ…

โŒ Don’t Do This:

rm -rf node_modules
<!-- Then expect tools to work without SDKs -->      # โŒ
# Assume all packages declare dependencies correctly
<!-- PnP will expose the broken ones -->             # โŒ
# Use React Native with PnP
<!-- Requires node_modules -->                       # โŒ

Common Pitfalls

PitfallWhy It HappensFix
IDE shows import errorsTypeScript not using PnP SDKRun pnpify --sdk vscode
Package fails to import peer depPeer not provided by consumerCheck packageExtensions or declare the peer
Tool cannot find binarynode_modules/.bin does not existUse yarn run <binary>
React Native failsPnP incompatible with RNUse nodeLinker: node-modules
Ghost dependency exposedPackage omitted declarationFix with packageExtensions

Real-World Examples

1. Enable PnP

nodeLinker: pnp

2. Run a Binary

yarn run jest

3. Fix Missing Dependency

packageExtensions:
  "some-lib@*":
    dependencies:
      lodash: "*"

4. Generate IDE SDK

yarn dlx @yarnpkg/pnpify --sdk vscode

5. Check PnP Mode

pnpMode: strict

6. Inspect .pnp.cjs

head -n 20 .pnp.cjs

7. Fallback to node_modules

yarn config set nodeLinker node-modules
yarn install

8. Zero-Install Setup

# Commit .yarn/cache and .pnp.cjs to git
git add .yarn/cache .pnp.cjs

9. List Dependencies

yarn why lodash

10. Update PnP Map

yarn install

Visual

PnP Resolution Flow

โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”
โ”‚  Source code: require('lodash')              โ”‚
โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜
                   โ”‚
                   โ–ผ
โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”
โ”‚  Node.js resolution                          โ”‚
โ”‚  (patched by .pnp.cjs loader)                โ”‚
โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜
                   โ”‚
                   โ–ผ
โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”
โ”‚  Is lodash in the dependency map?            โ”‚
โ”‚  โ”œโ”€ YES โ†’ Return location in cache           โ”‚
โ”‚  โ””โ”€ NO  โ†’ Throw semantic error               โ”‚
โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜
                   โ”‚
                   โ–ผ
โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”
โ”‚  Read from .zip archive in .yarn/cache       โ”‚
โ”‚  No extraction, no filesystem walk           โ”‚
โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜

The loader intercepts the resolution before the filesystem is consulted.

Ghost Dependency Prevention

โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”
โ”‚  Package A declares: express                 โ”‚
โ”‚  Package A does NOT declare: lodash          โ”‚
โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜
                   โ”‚
                   โ–ผ
โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”
โ”‚  Package A code: require('lodash')           โ”‚
โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜
                   โ”‚
                   โ–ผ
โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”
โ”‚  PnP checks Package A's dependencies         โ”‚
โ”‚  lodash is not listed                        โ”‚
โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜
                   โ”‚
                   โ–ผ
โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”
โ”‚  Semantic Error                              โ”‚
โ”‚  "lodash isn't declared in your dependencies"โ”‚
โ”‚  (Even if lodash exists elsewhere)           โ”‚
โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜

The strictness is the point. A package cannot access what it did not declare.


Summary

ItemValue
Default linkerPnP (Yarn Berry)
Install output.pnp.cjs
Package storage.yarn/cache/ (zip archives)
ResolutionLoader map, not filesystem
Ghost dependenciesBlocked
Semantic errorsYes, with context
IDE supportRequires SDK generation
FallbacknodeLinker: node-modules

Key takeaways:

  • PnP eliminates node_modules. The .pnp.cjs loader maps every package to its archive in the cache. Node.js resolves imports through this map, not through directory traversal .
  • Ghost dependencies fail immediately. A package that imports an undeclared dependency receives a semantic error identifying the exact problem, rather than failing silently in production .
  • Installs are faster and use less disk. PnP avoids file copies, storing packages in a shared compressed cache. The install operation is essentially generating the loader file .
  • Ecosystem compatibility requires effort. Tools that assume node_modules exists may fail. IDE SDKs and packageExtensions are the primary workarounds .
  • The strictness is a feature, not a bug. Packages that work under PnP are guaranteed to have complete dependency declarations, making them portable and predictable .

Remember: PnP is a different model of resolution, not a different way of writing files. The dependency graph is data, not a directory tree. The loader enforces the graph. If a package works under PnP, its dependencies are complete. If it fails, the failure identifies a real bug that node_modules would have hidden.

Was this helpful?


Stop using slow, ad-bloated tool sites! ๐Ÿคฎ

๐Ÿ”Ž Search “KandZ Tools” on Google to use many professional utilities for free.

KandZ.me is the ultimate minimalist hub for:
โœ… Finance (Mortgage, Interest, Inflation)
โœ… Tech (Base64, JSON, Dev Suite, IP)
โœ… Health (BMI, BMR, TDEE)
โœ… Productivity (Timer, Workspace, QR)

โšก๏ธ Fast & Private
๐Ÿ”’ No data leaves your device
๐Ÿ’Ž 100% Free

๐Ÿ”— Use it now: https://tools.kandz.me
๐Ÿ”– Bookmark itโ€”youโ€™ll need it later!