Lesson 1 of 9
The npm Ecosystem & package.json
"It's not that I'm so smart, it's just that I stay with problems longer." — attributed to Albert Einstein
You now understand JavaScript deeply — the language core, the runtime, closures, the event loop, asynchrony. That is how the machine thinks. Part VII is about something the language spec never mentions: how professionals ship it. Real software is not one file you run; it is dozens of dependencies, a build step, a test suite, a lint pass, a security posture, and a team who all need to reproduce the exact same thing on the exact same day. The connective tissue that makes all of that possible is a package manager and a single JSON file. Get this wrong and you get the most demoralising bug in our field — "works on my machine" — the one that isn't in your code at all. Get it right and a stranger can git clone your project and have it running, identically, in ninety seconds.
Your project depends on code you didn't write, pulled from the internet, resolving to versions nobody typed by hand. How do you make that install reproducible — the same tree on your laptop, your teammate's, and the CI server — and how do you keep it safe?
Core Concepts
npm is two things wearing one name. It is the registry — a public database at registry.npmjs.org holding over two million packages, the largest software collection on Earth. And it is the CLI — the npm command that ships with Node, which reads your package.json, resolves what you asked for against that registry, and writes the result into a folder called node_modules. That folder is not source you edit or commit; it is build output, regenerated on demand from two files you do commit. Everything in this chapter is about those two files.
package.json: the manifest
Every project has one. It is the project's identity card, its dependency contract, and its task runner all at once. Here is a representative one, annotated by section:
{
"name": "chika-academy",
"version": "2.4.1",
"type": "module",
"main": "./dist/index.js",
"exports": { ".": "./dist/index.js" },
"engines": { "node": ">=20.0.0" },
"scripts": {
"dev": "vite",
"build": "vite build",
"test": "vitest run",
"lint": "eslint ."
},
"dependencies": {
"react": "^18.3.1",
"zod": "~3.23.8"
},
"devDependencies": {
"vite": "^5.4.0",
"vitest": "^2.0.0"
},
"peerDependencies": {
"react": ">=18"
}
}Read it top to bottom. name and version identify the package (and are all that's required to publish). type is the one that ties straight back to Part II: "module" means every .js file is an ES module — import/export, top-level await; "commonjs" (or omitting it) means require/module.exports. This single field decides how the runtime parses your files. main is the legacy entry point when someone imports your package; exports is the modern replacement — it lets you declare exactly which files are importable and hide everything else, so node_modules internals stay private. engines records the Node versions you support; teams can make a mismatch a hard error.
Then the three dependency buckets, and the distinction is not cosmetic:
| Field | Installed for consumers? | Meaning |
|---|---|---|
dependencies |
Yes | Needed at runtime. Ships with your code. |
devDependencies |
No | Needed only to build/test/lint. Bundlers, test runners, types. |
peerDependencies |
No (must be provided) | "I work with this, but the host app supplies it." Plugins and libraries. |
Putting a test runner in dependencies bloats every downstream install; putting a runtime library in devDependencies means production crashes with "module not found." peerDependencies solves a subtler problem: a React plugin must use the same React as the app, not its own copy — so it declares React as a peer and lets the app own the single instance.
Think of a recipe card. dependencies are the ingredients that go in the cake — flour, eggs — present in the final product on the plate. devDependencies are the mixing bowl, the whisk, the oven: essential to make the cake, absent from it. peerDependencies are the "serve with your own ice cream" note — the recipe assumes you already have some and refuses to bundle a tub. Ship a whisk inside your cake and everyone downstream pays to carry it.
Semantic versioning: the contract in three numbers
Every version is MAJOR.MINOR.PATCH, and the numbers are a promise. PATCH (2.4.1 → 2.4.2): backwards-compatible bug fixes only. MINOR (2.4.1 → 2.5.0): new features, still backwards-compatible — old code keeps working. MAJOR (2.4.1 → 3.0.0): a breaking change; something you relied on is gone or different. That is the entire deal, and it is what lets you accept some updates automatically while refusing others.
You express your appetite for updates with a range operator in front of the version:
| Range | Reads as | Matches | Doesn't match |
|---|---|---|---|
^1.4.2 |
"compatible with" — same MAJOR | 1.4.2, 1.5.0, 1.99.0 | 2.0.0 |
~1.4.2 |
"approximately" — same MINOR | 1.4.2, 1.4.9 | 1.5.0 |
1.4.2 |
exact pin | 1.4.2 only | everything else |
* |
anything | any version | — |
^ (caret) is npm's default when you npm install a package, and it embodies a bet: the ecosystem honours semver, so any minor or patch bump is safe to take automatically. Mostly true — and occasionally the source of a 2am incident when a "patch" release quietly wasn't. Let's make "satisfies" concrete. The logic below is a stripped-down version of what npm runs millions of times a day:
Notice what this means: ^ and ~ describe a set of acceptable versions, not one. So which one actually lands in node_modules? That is the job of the lockfile.
The lockfile: from "acceptable" to "exact"
package.json says "I'll take any ^18.3.1 React." Run npm install today and you might get 18.3.1; run it in three weeks and you might get 18.4.0 — both satisfy the caret. Multiply that ambiguity across hundreds of transitive dependencies (the deps of your deps) and two installs of the "same" project can differ in a thousand places. That is the mechanical root of "works on my machine."
package-lock.json closes the gap. It records the exact version of every package in the entire resolved tree — direct and transitive — plus a cryptographic integrity hash of each one's contents. Commit it. It is not noise; it is the difference between "reproducible build" and "a hopeful guess." With it, everyone installs the identical tree, and the hashes let npm detect if a downloaded package was tampered with in transit.
The install commands, and when each earns its keep:
• npm install — resolves ranges, may update the lockfile, writes node_modules. Your day-to-day command while developing.
• npm ci — "clean install." Deletes node_modules, installs the lockfile exactly, and errors if package.json and the lockfile disagree. Never mutates the lockfile. This is the CI/CD command — fast, deterministic, honest.
• npm run <name> — runs a script from the scripts block.
• npx <tool> — runs a package's binary without a global install (e.g. npx eslint .), fetching it on the fly if needed.
Scripts: the project's task runner
The scripts block is a convention so universal it functions as documentation: any developer knows npm run dev starts the thing, npm test tests it, npm run build produces the artefact. Inside a script, node_modules/.bin is on the PATH, so you write vite, not the full path to its binary. Scripts also have lifecycle hooks: a script named pre<x> runs automatically before <x>, and post<x> after. Define prebuild and it fires before build without you asking — handy for cleaning a dist/ folder or running type-checks as a gate.
npm install # dev install; may update the lockfile
npm ci # clean, lockfile-exact install — use in CI
npm run dev # run the "dev" script
npm test # shorthand for `npm run test`
npx create-vite@latest # run a package binary without installing it globally
npm audit # report known vulnerabilities in the treeThe Academy's build pipeline runs npm ci, never npm install. The reason is discipline: ci refuses to run if the lockfile and manifest have drifted apart, so a developer who bumps a dependency but forgets to commit the updated lockfile gets a red build, not a silently different production bundle. The same lockfile that reproduces the tree also makes deploys boringly identical — the styling, the behaviour, the exact minified output are the same every single time, which is precisely what you want from the thing thousands of students load.
npm shipped in 2010 as a weekend-ish side project by Isaac Schlueter, riding Node's rise. For years it had no lockfile at all — installs were genuinely non-deterministic, and "works on my machine" was an epidemic. Facebook's Yarn (2016) introduced a proper lockfile and shamed npm into adding its own in npm 5 (2017). Then came the 2016 left-pad incident: one developer un-published an eleven-line package, and thousands of builds across the industry — React, Babel, Node itself — broke within minutes. It was the day the world learned that a modern app is a tower of strangers' code, and that the tower needs locking down.
Common Mistakes
- Committing
node_modules. It's regenerable build output — huge, platform-specific, and pointless in git. Add it to.gitignore; commitpackage.jsonandpackage-lock.jsoninstead. .gitignore-ing the lockfile, or deleting it "to fix things." The lockfile is reproducibility. Removing it is how "works on my machine" is born, not cured.- Running
npm installin CI. It can mutate the lockfile mid-pipeline, so CI tests a tree that never gets committed. Usenpm ci. - Misfiling dependencies. A runtime library in
devDependenciescrashes production; build tools independenciesbloat every consumer. Ask: "is this needed when my code runs, or only when I build it?" - Trusting
^blindly on a critical dep. The caret auto-accepts minor updates. For something load-bearing, consider pinning exactly and upgrading deliberately. - Treating
npm auditwarnings as all-or-nothing. A "critical" in a dev-only, build-time dependency is not the same risk as one in a runtime dep exposed to user input. Read the path, then decide.
You install one small package. It has 3 dependencies; each of those has 3; and so on, four levels deep. How many distinct packages — strangers' code — now execute inside your project? Not 4. It's 3 + 9 + 27 + 81 = 120, from a single line in your package.json. Every one can run install scripts on your machine and ships in your build. This is the supply chain, and it is why "keep dependencies minimal" (Vol 0's security lesson) is not minimalism for its own sake — every dependency is a door, and you are trusting everyone who has a key to every door behind it.
Interview Corner
dependencies and devDependencies?"dependencies are needed when the code actually runs and ship to production; devDependencies are only needed to build, test, or lint — things like the bundler and test runner. A consumer installing my package gets the former, not the latter."^1.4.2 mean?" → "Any version with the same MAJOR that's at least 1.4.2 — so minor and patch updates, but not 2.0.0."package-lock.json, and when would you use npm ci over npm install?"package.json stores version ranges, so two installs can resolve to different trees. The lockfile pins the exact resolved version and integrity hash of every package, direct and transitive, making installs reproducible. npm ci installs that lockfile exactly, wipes node_modules first, and fails on drift — so I use it in CI and deploys, where determinism matters, and plain install while developing."peerDependencies for?" → "Declaring 'I work with X but the host app must provide it' — so a plugin shares the app's single copy of React instead of bundling its own."npm ci or npm install; an install in the pipeline can silently resolve a caret range to a newer transitive version. I'd diff the lockfile against last week's known-good deploy to find what moved, pin or roll it back, and add npm ci plus engines enforcement so the environment can't drift again. Then npm audit to check the mover wasn't a compromised release."package.json. Narrate why a dep is a dev-dep, don't just recite the definition.npm ci in pipelines, committing the lockfile, engines to pin the toolchain. Show you think about the whole team, not just your laptop.npm audit, minimal dependency surface, integrity hashes. Tie every dependency to blast radius.- In the editor above, add a case to
satisfies: does2.4.1satisfy^2.0.0? Predict the result, then run it. Now try3.0.0against the same range and explain the change. - In a scratch folder run
npm init -y, thennpm install zod. Open the generatedpackage.jsonandpackage-lock.json— find the caret npm chose, and findzod's own dependencies listed in the lockfile. - Out loud (as in a micro1 screen), explain in three sentences why deleting
package-lock.jsoncan make a bug appear on a teammate's machine but not yours. - Run
npm auditin any real project and read one finding end to end: which package, direct or transitive, and what the fix is.
Chapter Summary
- npm is both the world's largest package registry and the CLI that resolves your manifest into
node_modules— which is regenerable build output, never committed. package.jsondeclares identity (name,version), module system (type), entry points (main/exports), supported runtimes (engines), tasks (scripts), and three distinct dependency buckets.- Semver encodes a compatibility promise: MAJOR breaks, MINOR adds, PATCH fixes.
^accepts minor+patch,~accepts patch only, and a bare number pins exactly. - The lockfile turns version ranges into one exact, hash-verified tree — commit it, and "works on my machine" largely disappears.
npm ciinstalls that lockfile exactly and fails on drift; it's the command for CI and deploys.npm installis for development;npm runandnpxdrive tasks and tools.- Every dependency is trust and blast radius — keep the tree minimal, run
npm audit, and remember that hundreds of strangers' packages may hide behind one line.
You can now assemble a project from the whole ecosystem — but there's a gap between the code you write (dozens of ES modules, the latest syntax, maybe TypeScript) and the code a browser or old runtime can actually run. Something has to gather those modules, translate the new syntax to something universal, and squeeze it all into a fast, single artefact. That is the job of the next chapter: Bundlers & Transpilers — Vite, esbuild, and Babel.
Editorial reflection — the manifest and the lockfile look like plumbing, the boring files you scroll past to reach the "real" code. But they encode the two ideas that separate a script from a system: reproducibility (anyone, anywhere, gets exactly what you got) and trust (you know precisely whose code you are running, and can prove it hasn't changed). Master engineers respect these files the way surgeons respect sterilisation — invisible when done right, catastrophic when skipped.
Q&A · 0
Enrol to ask questions and join the discussion.
No questions yet — be the first to ask.