AV
HomeAboutProjectBlog

© 2026 Ave syah Shina. All rights reserved.

  1. Home
  2. Blog
  3. 07 — Package Managers — How I Reuse Other People's Code Safely

07 — Package Managers — How I Reuse Other People's Code Safely

August 12, 202611 min read
Download as Markdown

"The thing that makes node_modules appear" was my package-manager model, and it stopped working the first time a lockfile conflict appeared. The model that everything else hangs off: a package manager is how I reuse other people's code safely. I declare what I want, it resolves a tree of dependencies from a registry, and it pins the result so that what runs on my machine is what runs on yours [1].

The way of thinking that finally clicked is a three-part split, and getting those three parts cleanly separated is most of the value:

package.json declares WHAT I want names + version ranges "react": "^18.2.0" "next": "^16.0.0" lockfile pins EXACTLY what everyone gets full resolved tree integrity hashes every transitive dep node_modules/ the materialized result actual files on disk what the code imports declare ▸ resolve & pin ▸ materialize

Once I could see those three stages, the differences between npm, Yarn, pnpm, and Bun stopped being a feature checklist. They're four answers to the same three questions — how they resolve, how they pin, and how they materialize. Most of the "which one should I use" debate collapses to how each one answers the third question.

The three parts, and why each exists

package.json is the declaration. I list the packages I directly depend on and the version ranges I'll accept [2]. "react": "^18.2.0" means "any 18.x that's at least 18.2.0." Ranges, not exact versions, because I want bugfix patches to flow in. The declaration is small, human-edited, and checked into git. It answers what do I want, nothing more.

The lockfile is the pin. This is the part I underweighted for years. The lockfile records the exact version of every package that got installed — including every transitive dependency I never asked for directly, plus an integrity hash for each [3]. It exists for one reason: so that the build on my machine, the build in CI, and the build on a teammate's fresh clone all resolve to the same tree. Without it, two npm install runs a week apart can quietly produce different node_modules from the same package.json, because a dependency published a new patch. The lockfile is the contract. It is checked into git; the declaration alone is not enough.

node_modules is the materialized result. It's the actual files on disk that my import statements resolve to [1]. It is large, machine-generated, and gitignored. The mistake I made early on was treating node_modules as the source of truth — deleting it and re-installing would sometimes "change behavior," and I couldn't explain why. The truth is the lockfile is the source of truth; node_modules is just the rendered output, and the reason a fresh install could differ was that I didn't have a lockfile worth the name.

So the whole job is: declare loosely, pin exactly, materialize faithfully. Every package manager does this; they differ in how.

npm — the default, and the biggest

npm ships with Node, so it's the one I reached for first by sheer availability [2]. It reads package.json, writes package-lock.json, and installs into node_modules with a flattened tree — every package's dependencies get hoisted to the top level where possible, with nested copies only when versions conflict.

The flattening matters because Node's resolution algorithm walks up the directory tree looking for a module [1]. Hoisting means most packages end up at the top node_modules/ and a single import resolves. The cost is that this flat structure lets me require() packages I never declared — so-called phantom dependencies — which work until they suddenly don't. npm is the most popular choice and the lowest-friction default, and for most projects it's the right answer simply because it's already there [4].

Yarn — lockfiles first, then Plug'n'Play

Yarn was the first serious npm alternative, and its original contribution to my way of thinking was the lockfile: Yarn shipped yarn.lock with deterministic, checksummed resolution before npm had a stable equivalent [5]. The modern line is Yarn Berry, whose headline feature is Plug'n'Play (PnP) — instead of materializing node_modules at all, Yarn keeps packages as .zip archives in a cache and teaches Node to resolve imports directly from them [5].

The appeal is that there's no node_modules folder to write — installs are fast and projects stay small on disk. The catch is that PnP requires tooling to play along: anything that expects to find node_modules/ on disk (some bundlers, some IDEs, native add-ons) needs a compatibility shim or breaks. Yarn Berry is excellent in a controlled, monorepo-friendly setup; it asks more of the surrounding toolchain than npm does.

pnpm — the global store and strict symlinks

pnpm is where the model clicked hardest for me. Its defining choice is a global content-addressable store: every version of every package is downloaded exactly once, machine-wide, and each project's node_modules is built from symlinks into that store [6]. Two consequences fall out of that, and they're the reason pnpm exists.

First, disk efficiency. Ten projects depending on React 18.2.0 share one copy of those files on disk, not ten [6]. For a machine with many repos this is a real saving — my ~/.pnpm-store is a few gigabytes where an npm-style setup would be many tens.

Second, strictness. Because the on-disk layout uses symlinks arranged to match the declared dependency tree, pnpm does not flatten the way npm does. A package can only import what it declared in its own package.json — phantom dependencies simply fail to resolve [6]. This bit me once when a project that "worked fine under npm" broke under pnpm: it had been silently importing a transitive dependency it never declared. pnpm surfaced the bug that npm's flat hoisting had been hiding. That strictness is the feature, not the downside.

npm / Yarn classic — copies per project proj A/ react@18 lodash@4 react@18 proj B/ react@18 lodash@4 proj C/ react@18 react@18 same files, stored many times pnpm — global store + symlinks ~/.pnpm-store (one copy each) react@18 · lodash@4 A/ symlinks → store B/ symlinks → store C/ symlinks → store stored once, referenced everywhere

pnpm also has first-class monorepo support (workspaces) and, because of the strict layout, stronger isolation between packages [6]. If npm's flat hoisting is permissive, pnpm's symlink layout is the strict one — and for any project where "it worked under npm but broke under pnpm" happens, the bug was always in the declarations, not in pnpm.

Bun — runtime, bundler, and package manager in one

Bun is the outlier, because it isn't really just a package manager [7]. It's a JavaScript runtime (an alternative to Node itself), a bundler, a transpiler, and a package manager, all built together and aimed at raw speed — it uses JavaScriptCore rather than V8 and is written in a lower-level stack optimized for fast installs [7].

The reason I keep it in a separate category in my head is that the other three are package managers that run on top of Node. Bun replaces the runtime too. So bun install is a drop-in package manager for a Node project — Bun reads package.json, writes a bun.lockb, and installs faster than the others — but it's also the entry point to a whole different toolchain (bun run, bun build) [7]. The package-manager piece is excellent and notably quick. Whether I'd adopt the rest of the runtime is a separate decision from whether I like its installs.

How I use this

The practical payoff is mostly one question when I start a project: which materialization strategy do I want? Default to npm when I want zero friction and the ecosystem's broadest compatibility; reach for pnpm when disk usage and dependency strictness matter (monorepos, many repos on one machine, or a codebase that's been quietly accumulating phantom imports); consider Yarn Berry when I'm committed to its toolchain and want to skip node_modules entirely; and treat Bun as a fast installer I can adopt incrementally, with the option of the full runtime later. In every case the three-part model still holds — declare in package.json, pin in the lockfile, materialize in node_modules (or its equivalent). The lockfile is the part I check in and trust; everything else is rendering.

References

[1] npm, Inc., "About npm," npm Docs, 2024. [Online]. Available: https://docs.npmjs.com/about-npm

[2] npm, Inc., "Specifics of npm's package.json handling," npm Docs, 2024. [Online]. Available: https://docs.npmjs.com/cli/v10/configuring-npm/package-json

[3] npm, Inc., "package-lock.json," npm Docs, 2024. [Online]. Available: https://docs.npmjs.com/cli/v10/configuring-npm/package-lock-json

[4] NodeSource, "An Absolute Beginner's Guide to Using npm," 2023. [Online]. Available: https://nodesource.com/blog/an-absolute-beginners-guide-to-using-npm/

[5] Maël Nison and contributors, "Yarn — Plug'n'Play," Yarn Docs, 2024. [Online]. Available: https://yarnpkg.com/features/pnp

[6] Zoltan Kochan and contributors, "About pnpm," pnpm.io, 2024. [Online]. Available: https://pnpm.io/about

[7] Oven-sh, "Bun — Package Manager," Bun Docs, 2024. [Online]. Available: https://bun.com/docs/install

Knowledge check · Question 1 of 5

Which file declares WHAT packages you want, as version ranges?

Comments

Leave a Comment

You must be signed in to comment

0 Comments

No comments yet. Be the first to comment!