You declare a handful of names and version ranges. A resolver turns that into hundreds of packages you never chose, downloads them, arranges them on disk, and runs some of their code. Almost every confusing failure here lives in the gap between what you declared and what you got: the build that worked yesterday, the library loaded twice, the import that resolves on your laptop but not on the build server. This guide is about where each decision is made and which file controls it. Once you can say which step chose a version, the fix is usually one line.
Each chapter opens with the short version. Tap one to read the detail.
What a package actually is
~2 min
A package is a folder with a package.json, shipped as one tarball under a name@version whose contents can never be rewritten. What goes into that tarball is decided by the manifest, not by what your repository tracks.
A registry is a database of those tarballs and their metadata. npm, pnpm and Yarn are competing clients of the same public one, so switching tools does not change where your packages come from.
The manifest names it. name must be lowercase, URL-safe and at most 214 characters including any @scope/ prefix; version must parse as a version number. Together they form a permanent address: a release can be deprecated, but its bytes are never replaced — which is what makes pinning worth anything.
The misconception worth killing early is that publishing ships what your repository tracks. It does not. The default is everything in the directory, filtered by files if present, else .npmignore, else .gitignore — and package.json, the readme, the license and the main file always ship regardless.
In a folder whose .gitignore listed node_modules and dist, a dry run packed five entries: .env, NOTES.md, src/index.ts, package.json and dist/index.js. Ignoring dist in git did not keep it out, and nothing kept the secrets file out.
npm pack --dry-run # prints the exact tarball contents
Adding "files": ["dist"] cut that to two entries. Setting "private": true makes publishing refuse outright.
Rule: run a packing dry run before you publish — it is the only thing that tells you what you actually ship.
Dependency kinds, and why the wrong one breaks a consumer
~2 min
dependencies are installed transitively by everyone who installs you. devDependencies never leave your repository. Filing something under the wrong one is the classic library bug, and it is invisible on your own machine because your machine has both.
dependencies are what the shipped code needs while running. Every consumer gets them, and so does every consumer of that consumer. devDependencies are your build and test tooling; a consumer never installs them, and --omit=dev leaves them off disk, which is why a deployment image must not need a build-only tool.
The trap is that both are installed locally, so a misfiled dependency works perfectly for the author. Put your logging library in devDependencies and your own tests still pass — the consumer is the one who gets Cannot find module at run time. Put your test framework in dependencies and it lands silently in every downstream install, dragging its own subtree along.
optionalDependencies are allowed to fail: a build failure there does not fail the install, so code touching one must handle its absence rather than assume it arrived.
The check costs a minute and catches both directions:
npm pack # build the real tarball
cd /tmp/empty && npm init -y
npm install /path/to/pkg.tgz # install it like a stranger would
Rule: ask whether the shipped code needs this while running. Yes means dependencies; only the build or tests need it means devDependencies — and prove it by installing your own tarball into an empty folder.
What a version number promises, and what a range asks for
~2 min
The three parts are a publisher's claim — a major may break you, a minor adds, a patch fixes — and nothing enforces it. A range turns that into "the newest release that fits", which is why an unchanged manifest can install different code on two days.
Major, minor and patch mean incompatible change, backward-compatible addition, and backward-compatible fix. Below 1.0.0 all of that is suspended: the specification says anything may change at any time.
Ranges express how much you will accept. A caret takes anything up to the next major, a tilde takes patches, a bare number pins exactly. The mistake is reading a caret as "safe, small changes only" — it protects the leftmost non-zero part, so ^0.2.3 will not take 0.3.0, while ^1.2.3 happily takes 1.9.9, a jump across dozens of releases nobody read.
Two more rules matter. A prerelease is not matched by a range that does not itself name one, and 1.0.0-beta.1 sorts below1.0.0 rather than above it. Build metadata after a + is ignored entirely when comparing.
The claim is not a fact. A project asking for express at ^4.18.0 received 4.22.2: the resolver compared numbers and took the highest allowed, and nothing inspected the code to check that four minor releases had really broken nothing.
Rule: read a caret as "whatever the publisher currently says is compatible" — a bet on their discipline, not a guarantee, and not reproducible on its own.
The lockfile is what makes an install reproducible
~2 min
A range says what is acceptable; the lockfile records what you actually got — exact version, tarball location and integrity hash for every package in the tree. Commit it. An ordinary install may rewrite it; a frozen install obeys it.
The lockfile describes the exact tree that was generated, so later installs rebuild it identically regardless of what has been published since. It belongs in source control: it is what makes your machine, a colleague's machine and the build server agree.
Each entry carries the resolved version, the tarball it came from, and an integrity hash verified on download — so a tarball swapped after the fact fails rather than installs. It is never published: a library's lockfile has no effect on its consumers, who resolve your ranges themselves.
The misconception is that having a lockfile makes builds reproducible. Having one and obeying it are different commands. An ordinary install may add packages and rewrite the file, so its result depends on when you ran it. The frozen form wipes node_modules, installs exactly what is written, and never writes back.
With express pinned at 4.22.2, editing the manifest range to ^5.0.0 and running the frozen install produced:
npm error code EUSAGE
npm error `npm ci` can only install packages when your package.json and
npm error package-lock.json ... are in sync.
npm error Invalid: lock file's express@4.22.2 does not satisfy express@5.2.1
A plain install would have resolved a whole new tree and reported success. Other managers spell the same intent --frozen-lockfile and --immutable; the formats do not interoperate, so two lockfiles in one repository means two trees.
Rule: install loosely where drift is a decision you can see, frozen everywhere the result has to be reproducible.
node_modules, hoisting, and the tree you did not choose
~2 min
Lookup is simple: a bare name is searched for in the nearest node_modules, then each parent directory, up to the root. Everything strange about installed trees follows from that — packages get lifted to the top so one copy serves many dependents.
That upward walk is the whole mechanism. Because a package deep in the tree can see a copy near the root, installers flatten aggressively, hoisting shared packages to the top level instead of nesting them under each dependent.
Scale reframes the rest. A project declaring exactly one dependency installed 68 packages, 67 of them at the top level of node_modules. That ratio is normal, and it is the honest answer to what you are shipping and trusting.
Two consequences. The first is the phantom import: in that project require('cookie') worked even though nothing declared it, because a hoisted copy happened to be there for something else. Drop the package that pulled it in — or install with a tool that lays the tree out differently — and an import that "always worked" disappears.
The second is duplication. When two dependents need incompatible ranges, the loser gets a nested copy, and two copies are two separate modules with separate state:
An object built by one copy fails instanceof against the class from the other. That single fact explains a family of "impossible" bugs in contexts, registries and singletons.
overrides forces a version onto a transitive package you do not control. Forcing cookie to 0.7.2 under an express declaring 0.5.0 installs cleanly and leaves that dependent running against a version its own manifest never sanctioned — a stopgap for an urgent advisory, not a fix.
Rule: declare every package you import, even when it already resolves.
Peer dependencies: one copy, shared
~2 min
A peer dependency says "I plug into the copy you already have", so the host supplies the single instance. Two packages demanding incompatible host ranges is a real conflict, and the flags that silence it install a pair the publisher says does not work.
This exists because of the duplication in the previous chapter. A plugin that listed its host as an ordinary dependency could end up with a private copy, and then hooks, contexts and registries split silently across two module instances. Declaring the host as a peer moves that choice to the consumer, who has exactly one.
Modern npm installs peers for you: declaring only react-dom at 19.0.0 installed react as well, unasked. Ask for a mismatched pair and it stops instead:
npm error code ERESOLVE
npm error Found: react@18.2.0
npm error Could not resolve dependency:
npm error peer react@"^19.0.0" from react-dom@19.0.0
The misconception is that this is the installer being fussy. It is a statement that nothing satisfies both packages, and the two ways past it are worth understanding before use: --legacy-peer-deps restores the older warn-and-continue behavior, and on that exact pair it installed react@18.2.0 beside react-dom@19.0.0 — a combination the publisher has explicitly declared incompatible. The install succeeds; the failure moves to run time, where it is far harder to trace.
peerDependenciesMeta marks a peer optional, suppressing the warning when it is genuinely absent — for a plugin that adapts rather than requires.
Rule: treat a peer conflict as information, not an obstacle. Move one of the two versions, or wait for a release whose peer range covers your host; a flag hides the disagreement without resolving it.
Scripts, executables, and code that runs on install
~2 min
npm run puts node_modules/.bin on the path, so a script calls this project's copy of a tool by bare name. Lifecycle hooks go further: installing a package can execute its code on your machine, with your privileges.
A package's bin field maps a command name to a file, which the installer links into node_modules/.bin. Running a script prepends that directory to the path, so a tool named in a script means this project's version rather than whatever is installed globally. That is why two projects on one machine can use different versions of the same tool without conflict.
npx extends the idea: it runs the project-local binary when there is one, and otherwise fetches the package into a cache and prompts first. The prompt is not ceremony — a mistyped package name is how you run a stranger's code.
Beyond scripts you invoke, some fire on events: preinstall, postinstall, prepare and prepublishOnly, plus automatic pre and post wrappers around any script you define.
That last category deserves a demonstration rather than a warning. A dependency whose entire content was a postinstall hook, installed normally, wrote a file into the home directory as the current user — no build step, no call into the package:
npm install ./dep # postinstall ran; file appeared
npm install ./dep --ignore-scripts # nothing ran
"Installing is only downloading" is the misconception, and it is wrong by default rather than in exotic cases.
Rule: call local tools through npm run so versions stay per-project, and treat install-time code execution as the normal behavior it is.
Supply chain: what you are actually trusting
~2 min
Your trust boundary is the entire transitive tree, not your direct list. The recurring incident is a trusted name shipping a patch with a hostile install script. Auditing reports known advisories, which is useful and is not the same thing as safety.
In the earlier example, 67 of 68 installed packages were chosen by somebody else. Each can run code at install time and ships code you execute. That is the surface, and it does not shrink because your own list is short.
Three shapes account for most incidents: a near-miss package name banking on a typo or a copy-pasted command; a public package matching an internal name, which can win resolution unless scopes and registries are pinned; and a compromised maintainer account publishing a routine-looking patch whose install hook does the work.
What helps, roughly in order of leverage: a committed lockfile, whose integrity hash is checked on download and detects a tarball swapped after the fact; disabling lifecycle scripts on install; pinning a registry per scope; and keeping the direct list short, since every direct dependency is a subtree.
npm audit sends a description of your resolved tree to the registry and asks which known advisories match. audit fix runs a full install underneath, and --force accepts major upgrades — so it can break the build it was meant to protect.
A clean report does not mean nothing is wrong, only that nothing reported matched. And severity is not exposure: a critical advisory in a build-only tool may be unreachable from anything you ship. Provenance attestations tie a tarball to the build that produced it, and npm's own documentation is explicit that provenance still does not guarantee the code is not malicious.
Rule: reduce what you trust before trying to verify it — audit is a report, not a control.
Entry points, and the two module systems
~2 min
The exports field decides what a consumer may import: a file sitting in the tarball that exports does not list is unreachable. Conditions can send import and require to different builds of one library.
Before exports existed, every file in a package was importable by path, so consumers reached into internals and refactoring anything broke someone. exports makes the public surface explicit, and the common mistake is reading it as documentation when it is enforcement:
// package.json: { "exports": { ".": "./src/index.js" } }
require('toy/src/internal.js')
// ERR_PACKAGE_PATH_NOT_EXPORTED — the file exists, it is just not listed
Deleting the field made that same import work again. The consequence people miss runs the other way: adding exports to an established package breaks every consumer who was deep-importing, so it is a major change, not tidying.
Conditions like import, require, node and browser map one entry point to different files per consumer, matched top to bottom — so types goes first and default last. The hazard is shipping both a CommonJS and an ECMAScript build: a mixed graph can load both, giving one library two module states, the same split-state failure as a duplicated copy.
Format is decided by extension first — .mjs and .cjs always win — then by the "type" field.
The interop rules moved recently, so check your runtime rather than older advice. On Node 23, require() of an ECMAScript module works when the graph is synchronous; support was unflagged there and backported to 22.12 and 20.19. A graph using top-level await still cannot be required and reports ERR_REQUIRE_ASYNC_MODULE, with await import() as the way through.
Rule: ship one module format per package where you can, and when you must ship two, make sure a single graph cannot load both.
Monorepos, and the manager you install with
~2 min
Workspaces make sibling folders resolve as though they were published: one install, one lockfile, and an edit in one package is immediately live in another. Which manager you use changes the on-disk layout and what your code can accidentally import.
A glob list in the root manifest is the whole configuration. Installing links each workspace into the root node_modules, so a dependency on a sibling resolves to that folder — a link, not a copy:
node_modules/@demo/core -> ../../packages/core
package-lock.json # one, at the root; none in the packages
Editing the sibling's source changed what the dependent package saw immediately: no publish, no version bump, no reinstall. That single root lockfile is what keeps versions aligned across the repository. Targeting is per-workspace with -w <name>, or across all of them with --workspaces.
The trap is that a monorepo looks unified without being consistent: two workspaces pinning different majors of one library reintroduce the duplicate-instance problem inside a repository that appears to have solved it.
Managers differ in ways that matter here. pnpm keeps one content-addressed store hard-linked into each project and links only declared dependencies into each package, so the phantom import from the resolution chapter fails loudly instead of working by accident. Yarn's Plug'n'Play removes node_modules altogether and resolves through a manifest, which is faster and breaks tools that expect real directories.
The packageManager field declares the tool and version a repository expects. Corepack — shipped with Node from 14.19 through the 24 releases — proxies pnpm and yarn to that pinned version so contributors do not silently use different ones.
Rule: one manager and one lockfile per repository; mixing two produces two trees, and the drift stays invisible until a build differs.
The build step: bundling, shaking and targets
~2 min
A bundler walks the module graph from an entry point and emits fewer files. How much it can delete depends on whether your modules are statically analyzable and whether packages told the truth about side effects.
From one or more entry points a bundler builds a dependency graph of everything reachable and combines it into output bundles. It resolves the exports conditions too, adding its own — browser, development, production — so one package can hand a different file to the browser than to Node.
Dead-code elimination relies on the static structure of import and export: the tool must know what a module exports without running it. A CommonJS build defeats this, because exports are assigned at run time.
sideEffects: false is a package promising that importing a file does nothing but define exports. The documented failure is a component library that claims this while importing its own stylesheet:
// packages/ui/Card/index.js
import './Card.css'; // a real side effect the flag denies
The bundler drops that import, the component renders unstyled, and nothing errors. Mislabeling here fails silently — there is no log line to catch it.
Marking code unused is also not removing it: without minification enabled, unused exports are annotated and stay in the output.
Targets are two problems people merge into one. A transpiler rewrites syntax for older runtimes; a missing run-time method needs a polyfill shipped alongside your code, which no syntax transform provides. A lower target also emits more helper code, so an over-conservative one costs bytes for every user.
engines declares the runtime range you support. With ">=99" an install merely warned EBADENGINE; with engine-strict set it failed. Advisory unless someone opts in — and it documents the runtime rather than installing it.
Rule: measure the built output rather than reasoning about it — every claim here is visible in a bundle report and invisible in the source.
Written by Keentune. We are not affiliated with or endorsed by the organizations whose documentation informs this guide, and any linked sources belong to their respective owners.
All exam, test, and product names and trademarks are the property of their respective owners and are used here for identification and reference only. Keentune is independent study practice — not affiliated with, authorized, or endorsed by any of these organizations.