JAVASCRIPT / MODULES AND DEVELOPER TOOLING
package.json scripts and dependency fields
Sort every package into the right dependency field and write npm scripts that call local tools, forward flags, and use pre/post hooks correctly.
What you will learn
- Expand npm run <name> into pre<name>, <name>, post<name> before debugging a script
- Call local CLIs by bare name: npm run puts node_modules/.bin on PATH
- Put a package in dependencies only if published runtime code imports it
- Declare peerDependencies when the host app must own the single shared copy
Understanding package.json scripts and dependency fields
A package.json is a data file, so nothing in it executes on its own; npm is the program that reads it and acts. The scripts object maps a name to a string that npm hands to a shell, and the one thing npm adds is prepending node_modules/.bin to PATH for that shell. That is why "vitest run" works as a script even though typing vitest in your own terminal reports command not found unless you installed it globally. npm also chains hooks, running prebuild and postbuild around build, which is how a single name can mean clean, then compile, then report size.
The dependency fields look like a ranking of importance, but they are really a schedule of who needs the package and when. dependencies means the code you publish needs it at runtime, so npm installs it for anyone who installs you; devDependencies means only this repository's own workflow needs it, so it is installed here, skipped by npm install --omit=dev, and never traversed for consumers of your package. peerDependencies says the surrounding application must supply the package, which is how a plugin guarantees one shared copy of a framework instead of a second one loading beside it. optionalDependencies says a failed install is tolerable, which obliges your code to keep working when the module is absent.
The two halves meet at a boundary: scripts consume devDependencies while shipped code consumes dependencies, and most deployment surprises are a package sitting on the wrong side of that line. Arguments need care too, because npm parses anything after a script name as its own options, so npm run lint -- --fix is what actually places --fix in front of eslint. While a script runs, npm exposes manifest values as environment variables such as npm_package_version and the current script name as npm_lifecycle_event, so a build helper can read the version instead of hardcoding it.
// A package.json is just data; npm reads it and acts on it.
const pkg = {
name: "widget-lib",
version: "2.1.0",
scripts: {
prebuild: "rimraf dist",
build: "esbuild src/index.js --bundle --outfile=dist/index.js",
postbuild: "node scripts/report-size.js",
test: "vitest run",
start: "node server.js"
},
dependencies: { "lodash.merge": "^4.6.2" },
devDependencies: { esbuild: "^0.21.0", rimraf: "^5.0.0", vitest: "^1.6.0" },
peerDependencies: { react: ">=17" }
};
// npm run <name> expands to pre<name>, <name>, post<name> when those exist.
function plan(scripts, name) {
return ["pre" + name, name, "post" + name]
.filter((key) => key in scripts)
.map((key) => `${key}: ${scripts[key]}`);
}
console.log("npm run build executes:");
for (const line of plan(pkg.scripts, "build")) console.log(" " + line);
console.log("npm test executes:");
for (const line of plan(pkg.scripts, "test")) console.log(" " + line);
// Which field a name sits in decides whether it lands in node_modules.
const installed = (omitDev) =>
[
...Object.keys(pkg.dependencies),
...(omitDev ? [] : Object.keys(pkg.devDependencies))
].sort();
console.log("npm install installs:", installed(false).join(", "));
console.log("npm install --omit=dev installs:", installed(true).join(", "));
console.log("peerDependencies (host supplies):", Object.keys(pkg.peerDependencies).join(", "));Every entry in package.json answers one of two questions: what command should npm run under this name, and when does this package need to exist and for whom.
Worked examples
Reading manifest values inside a script
Shows how a build helper gets the package name, version, and current script name from the environment npm sets up.
// npm sets these before running a script; set them by hand so this is
// deterministic when you run it directly with node.
process.env.npm_package_name = "widget-lib";
process.env.npm_package_version = "2.1.0";
process.env.npm_lifecycle_event = "build";
const name = process.env.npm_package_name;
const version = process.env.npm_package_version;
const task = process.env.npm_lifecycle_event ?? "(not started by npm)";
console.log(`task ${task} for ${name}@${version}`);
console.log("banner:", `/* ${name} v${version} */`);
console.log("minify:", task === "build");Example explained
Line 1npm_package_name and npm_package_version are copied from the top-level manifest fields, so the version lives in one place only.
Line 2npm_lifecycle_event holds the script name npm is currently running, which lets one helper file behave differently under build and under test.
Line 3The ?? fallback matters because the same file may be run directly with node, where none of these variables exist.
Line 4Nothing reads package.json here; the values arrive as plain strings in process.env, so everything is a string even the version.
Auditing which field each import belongs in
Compares what the published code imports against the declared fields to find a runtime import stuck in devDependencies.
const pkg = {
dependencies: { "date-fns": "^3.6.0" },
devDependencies: { chalk: "^5.3.0", vitest: "^1.6.0" }
};
// Bare specifiers that the published source imports at runtime.
const runtimeImports = ["date-fns", "chalk", "picocolors"];
const runtime = new Set(Object.keys(pkg.dependencies));
const dev = new Set(Object.keys(pkg.devDependencies));
for (const name of runtimeImports) {
if (runtime.has(name)) {
console.log("ok " + name);
} else if (dev.has(name)) {
console.log("misplaced " + name + " (runtime import, declared dev-only)");
} else {
console.log("undeclared " + name + " (resolves only by luck)");
}
}Example explained
Line 1The two Sets mirror what npm does for a consumer: it installs the dependencies of your package and ignores its devDependencies entirely.
Line 2chalk resolves fine in your repo because your own devDependencies are installed there, which is exactly why this bug survives local testing.
Line 3picocolors is in neither field, so it currently resolves only because some other package pulled it into the top of node_modules.
Line 4The check is a plain data comparison, which is why tools can automate it: the manifest and the import list are both just names.
Surviving a missing optional dependency
Demonstrates the guard that optionalDependencies obliges you to write around a module that may not be installed.
// save as check.cjs and run: node check.cjs
function loadOptional(name) {
try {
return require(name);
} catch {
return null;
}
}
// Listed under optionalDependencies, so it may be absent after install.
const native = loadOptional("turbo-watcher-native");
const watcher = native ?? { kind: "polling", intervalMs: 300 };
console.log("native binding present:", native !== null);
console.log("watcher kind:", watcher.kind);
console.log("poll interval:", watcher.intervalMs);Example explained
Line 1require throws MODULE_NOT_FOUND for a package that never installed, and npm does not fail the install for an optional entry, so your code is the only place that notices.
Line 2The try/catch is the real meaning of optionalDependencies: the field is a promise that a fallback path exists, not a note that the package is unimportant.
Line 3?? substitutes the fallback object only for null or undefined, so a module that legitimately exports 0 or an empty string would still be used.
Line 4Platform-specific binaries are the usual case here, which is why the same manifest can install four packages on Linux and three on Windows.
Important notes
Only test, start, stop, and restart can be invoked without the run keyword; every other script needs npm run <name>, and npm run with no arguments prints the available names.
Script bodies are shell strings, so NODE_ENV=production node build.js and rm -rf dist behave differently under Windows cmd; put that logic in a Node script or a cross-platform helper instead.
Common mistakes
Installing a runtime package with npm i -D out of habit: everything works locally where devDependencies are present, then the production image built with --omit=dev or any consumer of the published package crashes on the first import.
Writing npm run test --watch instead of npm run test -- --watch: npm treats --watch as its own config, the test runner never receives it, the suite runs once and exits, and you conclude watch mode is broken.
Importing a package that was never declared but happens to sit at the top of node_modules because a dependency pulled it in: the build breaks later when that dependency drops or moves it, with no change of your own to blame.
Try it yourself
Change, predict, then run
In a browser editor, build a manifest object whose scripts are prelint, lint, and postbuild with no build script, then write hookChain(scripts, name) returning what npm run <name> would execute in order. Confirm it reports postbuild as an orphan hook that will never fire automatically.
Open the JavaScript workspaceCheck your understanding
A library's published source imports date-fns, but date-fns is listed only in the library's devDependencies. All local tests pass. What does an application that installs the library from the registry get?
- npm refuses the install because the manifest declares an import it does not depend on
- It works, because npm also installs the devDependencies of the packages you depend on
- date-fns is absent, so importing the library throws a module-not-found error at runtime
- npm rewrites the entry into dependencies during publish, so nothing breaks
Show answer
npm installs the dependencies of your dependencies but never their devDependencies, so date-fns is simply not there and the first import fails at runtime. Option 1 is tempting because devDependencies really do get installed, but only for the package in whose directory you run npm install; the field stops at that boundary by design. In practice the failure can look intermittent, since another dependency may happen to hoist date-fns into the top-level node_modules until it stops doing so.