# npm, package.json, and Dependencies

## 1. `package.json` — the Project's Manifest

Every npm package (including your own application) is described by a `package.json` at its root — the single source of truth for its name, version, dependencies, and how to run it.

```json
{
  "name": "my-app",
  "version": "1.2.0",
  "main": "index.js",
  "scripts": {
    "start": "node index.js",
    "test": "jest",
    "build": "tsc"
  },
  "dependencies": {
    "express": "^4.19.2"
  },
  "devDependencies": {
    "jest": "^29.7.0"
  },
  "engines": {
    "node": ">=18.0.0"
  }
}
```

`name` + `version` uniquely identify a published package on the npm registry; `main` points at the file `require("my-app")` resolves to when this package is depended on by something else.

## 2. `dependencies` vs. `devDependencies` vs. `peerDependencies`

```json
{
  "dependencies": {
    "express": "^4.19.2"
  },
  "devDependencies": {
    "jest": "^29.7.0",
    "eslint": "^9.0.0"
  },
  "peerDependencies": {
    "react": "^18.0.0"
  }
}
```

| Field | Installed in production? | Meaning |
|---|---|---|
| `dependencies` | yes | required for the package to actually run |
| `devDependencies` | no (`npm install --production` skips them) | only needed for developing/testing/building |
| `peerDependencies` | no — the CONSUMER must provide it | "this only works alongside a compatible version YOU install", used by plugins/libraries (e.g. a React component library declaring `react` as a peer, not bundling its own copy) |

Getting this split wrong is a common real-world bug: putting a build tool in `dependencies` bloats production installs; putting a runtime-required package in `devDependencies` means production deploys silently lack it.

## 3. Semantic Versioning (semver) Ranges

```
1.2.3
│ │ └─ PATCH: backwards-compatible bug fixes
│ └─── MINOR: backwards-compatible new features
└───── MAJOR: breaking changes
```

```json
{
  "dependencies": {
    "a": "1.2.3",   // EXACT version only
    "b": "^1.2.3",  // compatible with 1.2.3: allows 1.x.x, NOT 2.0.0 — most common default
    "c": "~1.2.3",  // allows 1.2.x only (patch-level only), NOT 1.3.0
    "d": ">=1.2.3 <2.0.0", // explicit range, equivalent in spirit to ^1.2.3
    "e": "*",       // any version at all — dangerous, rarely intentional
    "f": "1.2.3-beta.1" // a pre-release tag, excluded from normal range matching
  }
}
```

`^` (caret, npm's default when you `npm install foo`) is the loosest safe default: it trusts semver's promise that minor/patch bumps within the same major version don't break your code. `~` (tilde) is more conservative — only patch-level updates. An exact version pins completely, trading automatic bug fixes for zero surprise.

## 4. `package-lock.json` — Reproducible Installs

```json
{
  "name": "my-app",
  "lockfileVersion": 3,
  "packages": {
    "node_modules/express": {
      "version": "4.19.2",
      "resolved": "https://registry.npmjs.org/express/-/express-4.19.2.tgz",
      "integrity": "sha512-..."
    }
  }
}
```

`package.json`'s `"^4.19.2"` is a *range* — it doesn't pin an exact version, so two installs weeks apart (or on two different machines) could legitimately resolve to different actual versions (`4.19.2` vs. a newer `4.20.0` that satisfies the same range). `package-lock.json` records the **exact** resolved version (and a cryptographic integrity hash) of every package in the entire dependency tree at the moment `npm install` last ran, so `npm ci` (the lockfile-strict install command, used in CI) reproduces byte-identical `node_modules` every time. The lockfile should always be committed to version control; `node_modules` itself should not be.

## 5. `npm scripts`

```json
{
  "scripts": {
    "start": "node index.js",
    "dev": "nodemon index.js",
    "test": "jest",
    "pretest": "eslint ."
  }
}
```

```bash
npm run dev      # runs the "dev" script
npm start        # "start" and "test" get shorthand — no "run" needed
npm test
```

npm automatically runs a `pre<script>` before, and `post<script>` after, any script with a matching name (`pretest` above runs automatically before `test`) — a lightweight built-in hook mechanism with no extra tooling required. Scripts also get `node_modules/.bin` prepended to `PATH` for that invocation, which is why `"test": "jest"` works without a global Jest install — it resolves to the locally installed binary.

## 6. `node_modules` Resolution

```js
require("lodash");
```

When Node resolves a bare specifier like `"lodash"`, it walks **up the directory tree** from the requiring file, checking `<dir>/node_modules/lodash` at each level, stopping at the first match (or the filesystem root, throwing `MODULE_NOT_FOUND` if none is found). This is why a dependency installed at the project root is visible from a deeply nested file without any relative path — every ancestor directory's `node_modules` is a valid resolution target.

## Under the Hood: Why Nested `node_modules` Exist, and Why npm Mostly Avoids Them

### npm v3+ flattens the dependency tree wherever it can

Two packages, `A` and `B`, might both depend on `C` — but at potentially different, incompatible versions. If both `A@1.0.0` needing `C@^1.0.0` and `B@1.0.0` needing `C@^2.0.0` exist in the same project, npm cannot install a single shared top-level `C` that satisfies both. Its resolver flattens as much of the tree as possible into one shared top-level `node_modules`, but falls back to nesting a package-local copy (`node_modules/B/node_modules/C`) specifically for the conflicting version — the directory-walk resolution algorithm above means Node finds the nested copy first when resolving from inside `B`'s own files, and the top-level copy everywhere else. This is why `node_modules` trees can appear inconsistent in depth across a large project: it's conflict resolution, not randomness.

### `engines` is advisory, not enforced by default

```json
{ "engines": { "node": ">=18.0.0" } }
```

Declaring `engines.node` documents the minimum supported Node version but does **not**, by itself, block `npm install` on an incompatible Node version — it only produces a warning unless the project also sets `"engine-strict": true` in an `.npmrc`, or a CI pipeline explicitly checks it. Relying on `engines` alone to prevent "works on my machine" bugs from a Node version mismatch is a common false sense of security.

![npm hoists the first-seen version of a package to the top of node_modules and nests conflicting versions under the package that needs them; require walks up directories to find them](diagrams/01-hoisting-and-resolution.svg)

## Common Pitfalls

1. **Putting a build-only tool in `dependencies` instead of `devDependencies`** — bloats every production install with packages never actually needed at runtime.
2. **Not committing `package-lock.json`** — every fresh install can silently resolve different transitive versions, breaking the "works on my machine" guarantee the lockfile exists to provide.
3. **Assuming `^1.2.3` and `~1.2.3` behave the same** — `^` allows minor bumps (new features), `~` only allows patch bumps; picking the wrong one either over- or under-constrains updates.
4. **Running `npm install` in CI instead of `npm ci`** — `install` can still adjust the lockfile to satisfy ranges; `ci` fails fast if `package.json`/`package-lock.json` are out of sync, and always produces the exact locked tree.
5. **Assuming `engines.node` blocks incompatible installs** — it's advisory only unless `engine-strict` is explicitly enabled.

## Quick Reference

```
dependencies      → required at runtime, always installed
devDependencies   → dev/build/test only, skipped in production installs
peerDependencies  → consumer must provide a compatible version themselves
^1.2.3  → same MAJOR version (1.x.x), npm's default range
~1.2.3  → same MINOR version (1.2.x) only
package-lock.json → exact resolved versions, commit it, reproducible installs
npm ci            → lockfile-strict install, used in CI, never mutates the lockfile
npm run <script>  → runs scripts.<script>; pre<x>/post<x> run automatically around it
node_modules resolution → walks UP the directory tree, first match wins
```
