What Is pnpm?
You cloned a repo, saw a pnpm-lock.yaml instead of the package-lock.json you expected,
and now you are here. So: what is pnpm? It is a package manager for Node — a drop-in
alternative to npm and Yarn that installs the same packages from the same registry, but
stores them on disk in a completely different way.
That storage difference is the whole story. It makes installs faster, saves a lot of disk space, and — the part that actually matters for TypeScript — it makes your dependency graph honest. Packages you never declared stop being importable, which catches a class of bug that npm will happily let you ship.
This page covers how pnpm works, what changes in a TypeScript project, how workspaces behave in a monorepo, and the handful of places where the strictness bites.
What Is pnpm Doing Differently?
npm and Yarn Classic install packages by copying them. Every project on your machine gets
its own full copy of every dependency, flattened into one big node_modules directory. Ten
projects using the same version of typescript means ten copies on disk.
pnpm installs packages once, into a global content-addressable store, then links them into each project. The eleventh project costs almost nothing — no download, no copy, just links.
Two mechanisms do the work:
- Hard links from the global store into the project. The file exists once on disk; the project just points at it.
- Symlinks inside
node_modulesto build the dependency tree, so each package sees exactly the dependencies it declared and nothing else.
The store lives outside your project (run pnpm store path to find it). Deleting
node_modules and reinstalling is nearly instant, because nothing has to be fetched again.
The node_modules Layout
This is the part worth understanding, because it explains every surprise you will hit later.
With npm, node_modules is flat. Your direct dependencies and all of their transitive
dependencies get hoisted to the top level, side by side. With pnpm, the top level contains
only what you declared in package.json. Everything else lives in a hidden
node_modules/.pnpm directory and is wired up with symlinks:
node_modules/
typescript -> .pnpm/typescript@5.9.2/node_modules/typescript
.pnpm/
typescript@5.9.2/
some-transitive-dep@1.4.0/The consequence: phantom dependencies stop working. If your code imports a package you
never installed — but which happened to get hoisted because some other library depends on it
— npm resolves it fine and pnpm throws Cannot find module.
That failure is a feature. The import was always a bug; it just did not fail until the day the intermediate library dropped that dependency in a patch release and your production build broke for no visible reason.
What Changes in a TypeScript Project
Mostly nothing. pnpm add -D typescript and pnpm exec tsc behave exactly like the
npm equivalents, and your
tsconfig.json does not need a single pnpm-specific option.
The one area that needs attention is @types/* packages.
TypeScript picks up ambient type packages from node_modules/@types at the top level. Under
npm's flat layout, a transitive @types/node lands there by accident and your process
references type-check. Under pnpm, it does not — so if your code touches Node globals, you
must install the types yourself:
[object Object]With that in place, the usual things resolve:
const pnpmStorePath: string = process.env.PNPM_HOME ?? '~/.local/share/pnpm'
console.log(`store configured at ${pnpmStorePath}`)Without it, the compiler has never heard of process. The rule is simple and worth adopting
regardless of package manager: if you reference it, declare it. pnpm just enforces what
npm let you get away with.
The same logic applies to libraries. A package that ships its own .d.ts files works
unchanged. A JavaScript-only package needs its @types/* companion installed as a direct
dev dependency, not inherited from somewhere up the tree.
Workspaces in a Monorepo
This is where pnpm earns its reputation. Workspaces are declared in a separate
pnpm-workspace.yaml file rather than in package.json:
packages:
- 'packages/*'
- 'apps/*'Internal packages reference each other with the workspace: protocol, which tells pnpm to
link the local source instead of reaching for the registry:
{
"name": "@acme/web",
"dependencies": {
"@acme/types": "workspace:*"
}
}On publish, pnpm rewrites workspace:* into the real version number, so the protocol never
leaks into a published artifact.
For TypeScript specifically, the strict layout pays off twice over. Each package in the
monorepo can only import what its own package.json declares, which means a shared types
package cannot silently acquire a dependency on your web app's React version. Cross-package
imports resolve through the symlink to real source files, so project references and
tsc --build behave predictably.
A few commands you will use constantly:
pnpm -r build # run "build" in every workspace package
pnpm --filter @acme/web dev # run a script in one package
pnpm add -D typescript -w # add to the workspace rootIf install time or editor responsiveness in a large monorepo is your actual problem, the package manager is only one lever — the TypeScript performance guide covers the compiler side.
The Commands Worth Memorising
The mapping from npm is almost one to one:
| npm | pnpm |
|---|---|
npm install | pnpm install |
npm install pkg | pnpm add pkg |
npm install -D | pnpm add -D pkg |
npm uninstall | pnpm remove |
npm run build | pnpm build |
npx tsc | pnpm dlx tsc |
Two are worth calling out. pnpm exec runs a binary already installed in the project, while
pnpm dlx fetches a package temporarily and runs it — that is the real npx equivalent.
And pnpm why <package> prints the chain explaining why something is in your tree, which is
the fastest way to find out who dragged in that duplicate version.
To pin the package manager itself so everyone on the team uses the same one, add a
packageManager field to package.json:
{
"packageManager": "pnpm@10.4.1"
}Node's Corepack reads that field and uses the matching version, which ends the "works on my machine" lockfile churn that comes from two developers running different majors.
How It Compares to npm and Yarn
All three install the same packages from the same registry, so the choice is about layout, speed and strictness rather than what ends up in your bundle.
npm is the safe default. It ships with Node, every tool in the ecosystem assumes it works,
and its flat node_modules never surprises a bundler. The cost is disk space and a tree
that quietly lets undeclared imports resolve.
Yarn Berry (v2+) solved the same problems with Plug'n'Play, which drops node_modules
entirely and resolves modules through a single lookup file. It is the more radical design
and it is genuinely fast, but it needs editor and tooling support — for TypeScript that
means the Yarn SDK, and anything that reads files off disk the old way has to be patched.
pnpm sits in between. You still get a real node_modules directory that tools can walk, so
compatibility is close to npm's, but the layout is strict and the store is shared. For most
TypeScript projects that is the better trade: the migration is short, and the thing you gain
is a dependency graph you can trust.
Where pnpm Bites
Honest caveats, because they cost real time when you hit them cold:
- Tools that do not follow symlinks. Some older bundlers, test runners and React Native
setups walk
node_modulesdirectly and get confused by the virtual store. The escape hatch isnode-linker=hoistedin.npmrc, which gives you an npm-shaped flat layout while keeping the global store. Use it as a last resort, not a default — it gives back the phantom dependencies. - Peer dependency warnings get loud. pnpm reports mismatches that npm silently resolves. The warnings are usually correct, but on an old codebase you may have a long list to work through before the install is quiet.
- Migration surfaces real bugs. Switching an existing project often fails on the first
build. Nearly every time, it is a phantom import that was broken all along. Fix it by
installing the dependency properly rather than reaching for
shamefully-hoist=true. - ESM resolution still needs care. pnpm does not change how Node resolves modules, so if your project mixes module systems, the ESM rules apply exactly as before — pnpm just makes the resulting errors appear sooner.
Where to Go From Here
That is what pnpm is: the same packages, stored once and linked in, with a node_modules
that reflects what you actually declared. The disk savings are nice and the install speed is
nicer, but the strictness is the reason to switch — a dependency graph that lies to you is a
build that breaks at the worst possible moment.
For a TypeScript project, the migration is usually pnpm import to convert the lockfile,
pnpm install, then fixing whatever imports were phantom. After that it is business as
usual: the tsconfig options still decide how strict the compiler
is, the utility types are still where the language gets
interesting, and the best-practices post collects the
settings worth turning on once everything installs cleanly.
Become a TypeScript Pro
Track your progress through 200+ hands-on challenges. Free, sign in with GitHub.
Or start solving right away: explore all TypeScript challenges