Professional JavaScript

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.

The question this chapter answers

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.

Everyday analogy

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:

JavaScript — Try it Yourself
Loading editor…
Console
Runs automatically. Edit the code to see it change.

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 engineer's notebook

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 tree
Real-world example — chika.ng Academy

The 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.

History changed here

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; commit package.json and package-lock.json instead.
  • .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 install in CI. It can mutate the lockfile mid-pipeline, so CI tests a tree that never gets committed. Use npm ci.
  • Misfiling dependencies. A runtime library in devDependencies crashes production; build tools in dependencies bloat 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 audit warnings 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.
Thought experiment

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

Interview Corner — the same idea at three levels. Answer yours; recognise the one above it.
Junior
"What's the difference between dependencies and devDependencies?"
Model answer — "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."
Follow-up they'll throw: "What does ^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."
Intermediate
"Why commit package-lock.json, and when would you use npm ci over npm install?"
Model answer — "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."
Follow-up: "What are 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."
Senior
"A deploy that worked last week now behaves differently, with no code change. Where do you look?"
Model answer — "No code change means the inputs changed — and the biggest hidden input is the dependency tree. First I'd check whether the lockfile is committed and whether CI uses 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."
The senior move: treat the dependency tree as production state. 'No code change' is never 'no change' — the supply chain moved under you.
Company lens — how the same topic gets asked
micro1Live and conversational — the AI interviewer wants you reasoning out loud about a real package.json. Narrate why a dep is a dev-dep, don't just recite the definition.
GoogleWants precision on semver and resolution: explain caret vs tilde exactly, and how a lockfile turns a range into one deterministic tree.
MicrosoftValues reproducibility and process — npm ci in pipelines, committing the lockfile, engines to pin the toolchain. Show you think about the whole team, not just your laptop.
Amazon / AWSOperational and security lens: supply-chain risk, npm audit, minimal dependency surface, integrity hashes. Tie every dependency to blast radius.
Hands-on exercise
  1. In the editor above, add a case to satisfies: does 2.4.1 satisfy ^2.0.0? Predict the result, then run it. Now try 3.0.0 against the same range and explain the change.
  2. In a scratch folder run npm init -y, then npm install zod. Open the generated package.json and package-lock.json — find the caret npm chose, and find zod's own dependencies listed in the lockfile.
  3. Out loud (as in a micro1 screen), explain in three sentences why deleting package-lock.json can make a bug appear on a teammate's machine but not yours.
  4. Run npm audit in 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.json declares 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 ci installs that lockfile exactly and fails on drift; it's the command for CI and deploys. npm install is for development; npm run and npx drive 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.
Narrative bridge

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.