04 · npm & package.json¶
package.json is the manifest of a Node project: its name, how it runs, what it
depends on, and which Node versions it supports. npm (bundled with Node) reads it to
install dependencies and run scripts. Alternatives such as pnpm and Yarn read the same
file; the concepts here transfer directly.
A realistic package.json¶
{
"name": "orders-api",
"version": "0.3.0",
"private": true,
"type": "module",
"main": "src/server.js",
"engines": { "node": ">=22" },
"scripts": {
"start": "node src/server.js",
"dev": "node --watch --env-file=.env src/server.js",
"test": "node --test",
"lint": "eslint .",
"check": "npm run lint && npm test"
},
"dependencies": {
"express": "^5.1.0",
"pino": "^10.0.0"
},
"devDependencies": {
"eslint": "^9.0.0",
"supertest": "^7.0.0"
}
}
Field by field:
"private": trueprevents an accidentalnpm publishof an application."type": "module"makes.jsfiles ES modules (lesson 03).scriptsare named shell commands.npm startandnpm testare shortcuts; anything else runs withnpm run <name>.dependenciesare needed at runtime.devDependenciesare only needed to develop, test, or build. In production installs (npm ci --omit=dev) they are skipped, which shrinks your container image.
Semantic versioning and ranges¶
Versions are MAJOR.MINOR.PATCH. By convention, a major bump means breaking changes,
minor means new backward-compatible features, patch means fixes. Ranges in
package.json say which versions you accept:
| Range | Accepts | Notes |
|---|---|---|
^5.1.0 |
>=5.1.0 <6.0.0 |
npm's default when you npm install |
~5.1.0 |
>=5.1.0 <5.2.0 |
patches only |
5.1.0 |
exactly that | use npm install --save-exact |
^0.4.2 |
>=0.4.2 <0.5.0 |
for 0.x, caret is stricter: minor bumps may break |
Semver is a promise made by humans, and it's sometimes broken. That's the job of the lockfile.
The lockfile¶
package-lock.json records the exact version, download URL, and integrity hash of
every package in the tree — including dependencies of dependencies. Commit it.
npm installresolves ranges, may update the lockfile, and installs.npm ciinstalls exactly what the lockfile says, deletes any existingnode_modulesfirst, and fails ifpackage.jsonand the lockfile disagree. Use it in CI and Docker builds for reproducibility.
Day-to-day commands¶
npm install express # add a runtime dependency
npm install -D vitest # add a dev dependency
npm uninstall express # remove
npm outdated # what's behind, and by how much
npm update # move within your declared ranges
npm ls pino # why is this in my tree, and which version?
npm explain pino # who depends on it
npm audit # known vulnerabilities (Level 3 lesson 09)
npx eslint . # run a binary from node_modules/.bin (or fetch it)
Worked example: scripts with pre/post hooks and arguments¶
{
"scripts": {
"pretest": "npm run lint",
"test": "node --test",
"lint": "eslint src",
"db:migrate": "node scripts/migrate.js"
}
}
npm testautomatically runspretestfirst. (pre/posthooks exist for any script name.)- Pass extra arguments after
--:npm test -- --test-name-pattern=ordersrunsnode --test --test-name-pattern=orders. - Scripts run with
node_modules/.binon thePATH, soeslintworks withoutnpx. - Inside a script, npm exposes package fields as environment variables, e.g.
npm_package_version.
How It Actually Works¶
Building the tree. npm reads your direct dependencies, fetches metadata from the
registry (registry.npmjs.org by default), picks the highest version satisfying each
range, and recurses into their dependencies. Then it lays the result out on disk. Since
npm 3 the layout is hoisted and deduplicated: packages are placed as high as possible
in node_modules so they can be shared. If two packages need incompatible versions of
debug, one version sits at the top level and the other is nested under the package that
needs it:
node_modules/
debug/ (4.x, shared by most)
old-lib/
node_modules/
debug/ (2.x, only old-lib sees this one)
This works because of Node's module resolution (lesson 03): old-lib requiring debug
finds its own nested copy first. A side effect: your code can import a package you
never declared, because it happens to be hoisted. That is a phantom dependency and it
will break when the tree changes. pnpm avoids this with a content-addressed store and
symlinks that expose only declared dependencies.
The cache and integrity. Downloaded tarballs go into a local cache
(~/.npm/_cacache). The lockfile's integrity field is a hash (typically SHA-512) of the
tarball; npm verifies it on install, so a tampered or corrupted download fails loudly.
Lifecycle scripts. Packages may define preinstall, install, and postinstall
scripts that run on your machine during npm install — often to compile native code.
They are also a supply-chain attack vector. npm install --ignore-scripts (or
ignore-scripts=true in .npmrc) disables them; some packages then need a manual
build step.
npx first looks for the command in local node_modules/.bin, then in the npm cache,
and otherwise offers to download the package temporarily. Double-check names you run
with npx; a typo can fetch an unrelated package.
Common mistakes¶
- Not committing the lockfile, or deleting it to "fix" install problems. You lose reproducibility and can silently pick up new versions.
- Using
npm installin CI. Usenpm ci. - Putting build/test tools in
dependencies. They bloat production installs. - Importing a transitive dependency directly (phantom dependency). If you import it, declare it.
- Blindly running
npm audit fix --force. It can jump major versions and break your app. Read what it proposes.
Exercise¶
- Start a project, install
expressand-D supertest. Openpackage-lock.jsonand find theintegrityandresolvedfields forexpress. - Run
npm ls --all | head -40and find a package that appears at more than one version. Usenpm explainto see why. - Add
pretest,test, and acheckscript that chains lint and test. Pass a flag through with--and verify it reached the underlying command. - Delete
node_modules, runnpm ci, and compare the time withnpm install.