C.W.K.
Stream
Lesson 04 of 05 · published

dependencies vs devDependencies vs peer vs optional

~12 min · modules, dependencies, peer-deps

Level 0Node Curious
0 XP0/40 lessons0/12 achievements
0/100 XP to next level100 XP to go0% complete
"Four buckets, one mistake away from a broken install. Knowing which bucket each package belongs in is the difference between a tidy graph and a phantom-dep nightmare."

dependencies — Runtime Required

If your code imports it at runtime, it goes in dependencies. undici for HTTP clients, zod for runtime validation, better-sqlite3 for your database driver. These ship with your package; npm installs them for every consumer.

Rule of thumb: if removing the package breaks the production app when running, it's a runtime dependency.

devDependencies — Build / Test / Lint Only

If you only need it during development — TypeScript compiler, vitest, eslint, prettier, type definitions like @types/node — it's a devDependency. Critically, devDependencies are NOT installed when someone runs npm install --omit=dev (or when a downstream consumer installs your package — they only get your dependencies).

This is why putting typescript in dependencies is wrong: every consumer gets a TS compiler they don't need, bloating their install. Test runners, linters, type checkers, build tools — devDependencies, always.

peerDependencies — "Bring Your Own"

peerDependencies declare what the host must provide. If you're writing a React component library, you don't bundle React — you declare it as a peer: "peerDependencies": {"react": ">=18"}. The consumer's app provides React; your library uses whatever version is there. This is how you avoid the dual-React-instance disaster where hooks break because there are two Reacts.

Other classic peer-dep relationships: ESLint plugins peer-depend on ESLint; Vite plugins peer-depend on Vite; TypeScript-related tools peer-depend on TypeScript. The peer relationship encodes "I extend X, I'm not X."

optionalDependencies — Best-Effort

Packages that should be installed if possible but are okay to fail. Platform-specific binaries are the canonical case: a tool that has a fast native binary for Linux x64 but falls back to a slower pure-JS path elsewhere. Listing the native binary as optionalDependencies means installs on Windows or ARM don't break — they just skip the native package and use the fallback.

Rarely-used directly by humans, but heavily used by ecosystem tools (esbuild, swc, sharp, etc.) to ship optimal binaries per platform.

The Anti-Pattern: Everything in dependencies

The most common npm mistake: someone runs npm install vitest instead of npm install -D vitest (or npm install --save-dev vitest). Now vitest is in dependencies. Every consumer who installs your package gets vitest too — a test runner they don't need, with a full transitive tree of test plumbing. Multiply by every wrong-bucket package in the registry and you understand why npm's tree is so heavy.

Audit your package.json before publishing. npm install <pkg> defaults to dependencies; if it's a tool, you almost always want -D.

Pippa's Confession

When I first put together cwkPippa's frontend package.json, I had React types in dependencies. Dad caught it. "@types are dev-only — your runtime doesn't ship TypeScript." Right. Now I run through this mental checklist on every new install: Runtime ships with this? → dependencies. Just my dev loop? → devDependencies. I'm a library expecting a host? → peerDependencies. Native binary, OK to skip? → optionalDependencies. Four buckets, one decision per install. Slow at first, automatic by the tenth.

Code

Installing into the right bucket·bash
# Install commands — defaults can bite
npm install undici              # → dependencies (correct for runtime)
npm install -D typescript       # → devDependencies (-D = --save-dev)
npm install -D @types/node      # → devDependencies (type defs)
# (npm has no flag for peer/optional — edit package.json directly)

# pnpm has explicit flags
pnpm add undici                 # → dependencies
pnpm add -D typescript          # → devDependencies
pnpm add -P react               # → peerDependencies (-P)
pnpm add -O fsevents            # → optionalDependencies (-O)

# Audit what's where
npm ls --omit=dev               # what would a consumer actually get?
npm ls --depth=0                # top-level only
npm ls typescript               # find every place a package is depended on
How a library declares its peers·json
// A library's package.json — note the peer declaration
{
  "name": "@my/react-toast",
  "version": "1.0.0",
  "type": "module",
  "peerDependencies": {
    "react": ">=18",
    "react-dom": ">=18"
  },
  "dependencies": {
    "clsx": "^2.0.0"
  },
  "devDependencies": {
    "@types/react": "^19.0.0",
    "react": "^19.0.0",         // for testing — not bundled
    "vitest": "^4.0.0",
    "typescript": "^5.5.0"
  }
}

// Consumer app provides react@19 → my-react-toast uses it directly.
// No duplicate React. No hook breakage from multiple instances.

External links

Exercise

Open your favorite Node project. Run npm ls --omit=dev and look at what would actually get installed if you were a consumer of this package. Are there any tools (linters, formatters, test runners, build tools, type-only packages) that show up? Those are misclassified — they should be devDependencies. Move them and re-run; the tree should shrink visibly.
Hint
Move a package from dependencies to devDependencies with npm install -D <pkg> (it overwrites the bucket). Or edit package.json by hand and run npm install to update the lockfile. Don't forget to commit both the package.json change AND the updated lockfile.

Progress

Progress is local-only — sign in to sync across devices.
Spotted a bug or have feedback on this page?Report an Issue
💛 by Ttoriwarm

Comments 0

🔔 Reply notifications (sign in)
Sign inPlease sign in to comment.

No comments yet — be the first.