"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": {"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
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.