npm and package.json Explained: Semver, Lockfiles and npm ci
package.json declares a package's entry points, scripts and dependencies; npm installs them by semver range. Caret vs tilde, lockfiles, npm ci and resolution.
- Course: JavaScript study plan
- Module: Modules and Node
- Kind: Lesson
- Reading time: 12 min
- Runtime: Node 16
What is package.json in Node.js?
package.json is the manifest file that makes a directory an npm package. It gives the package's name and semver version, its entry points (main, exports, bin), whether .js files are ES modules (type), the scripts that npm run executes, and three kinds of dependencies that npm installs into node_modules. A committed lockfile then pins the exact versions installed.
Lesson
A package is a directory with a package.json; npm is the registry and the tool that installs packages into node_modules, and require/import of a bare name is the resolution algorithm that finds them there. Almost every JavaScript project is a package, depends on dozens more, and is shaped by a handful of package.json fields and one versioning convention — semver — that decides what npm install gives you today versus six months from now. This lesson covers the fields that matter, the resolution algorithm precisely enough to debug it, semver ranges and lockfiles, scripts, and the security realities of installing other people's code.
package.json, the fields that matter
{
"name": "@acme/tools", // scoped name; lower-case, URL-safe
"version": "2.3.1", // semver
"description": "…",
"type": "module", // .js files are ESM (omit or "commonjs" for CJS)
"main": "./dist/index.cjs", // legacy entry point for require()
"exports": { // modern entry points — the public surface; everything else is unreachable
".": { "import": "./dist/index.js", "require": "./dist/index.cjs" },
"./utils": "./dist/utils.js"
},
"bin": { "acme": "./bin/cli.js" }, // commands installed on PATH (npx acme, or after a global install)
"scripts": { "build": "tsc", "test": "node --test", "start": "node dist/index.js", "prepare": "npm run build" },
"dependencies": { "express": "^4.18.2" },
"devDependencies": { "typescript": "~5.4.0", "eslint": "8.57.0" },
"peerDependencies": { "react": ">=18" },
"engines": { "node": ">=18" },
"files": ["dist"], // what gets published (plus package.json, README, LICENSE)
"private": true // refuse to publish
}
dependencies are needed at runtime by whoever installs you; devDependencies only to develop the package itself (not installed by consumers); peerDependencies say "the host must provide this" (plugins, React components). exports restricts what consumers can import (deep imports of internal files break when you refactor otherwise) and supports conditions — import/require/node/default/types. engines documents the Node range and, with engine-strict, enforces it.
Resolution: how require("x") finds a file
- Core module? (
fs,node:fs) → built in. - Relative or absolute (
./,../,/) → try the exact path, then.js,.json,.node; then as a directory: itspackage.jsonmain, thenindex.js. - Bare specifier (
lodash,@acme/tools/utils) → look in./node_modules/, then../node_modules/, then../../node_modules/… up to the filesystem root. Inside the package directory, honourexportsif present (only the listed subpaths are allowed), elsemain, elseindex.js.
That upward walk is why nested node_modules work, why a monorepo can hoist shared packages to the root, and why the same package at two versions can exist twice in one tree (each dependant finds its nearest copy). require.resolve("x") prints the resolved path; node --trace-warnings and NODE_DEBUG=module show the search.
Semver
MAJOR.MINOR.PATCH (+ optional prerelease -beta.1): patch = bug fixes, minor = backwards-compatible features, major = breaking changes. Zero-major versions (0.x.y) promise nothing — any change may break. Range operators:
| Range | Means | Matches |
|---|---|---|
1.2.3 | exactly | 1.2.3 |
^1.2.3 | same major, ≥ 1.2.3 (default for npm install) | 1.2.3 … 1.99.99, not 2.0.0 |
^0.2.3 | for 0.x: same minor | 0.2.3 … 0.2.99 |
~1.2.3 | same minor, ≥ 1.2.3 | 1.2.3 … 1.2.99 |
>=1.2.0 <2.0.0 | explicit | |
1.x / 1.* | any 1.y.z | |
* / latest | anything | never in a real project |
Ranges are a promise about what you accept; whether the author kept the semver contract is another matter — hence lockfiles.
Lockfiles and reproducible installs
package-lock.json (npm), yarn.lock, pnpm-lock.yaml record the exact version and integrity hash of every installed package, transitive ones included. npm install respects the lockfile where it can and updates it when package.json changed; npm ci installs exactly the lockfile or fails, deleting node_modules first — use it in CI and deployments. Commit the lockfile; a project without one is a different program on every install. npm outdated and npm update are how you move ranges forward deliberately.
Scripts
npm run <name> runs scripts.<name> with node_modules/.bin on PATH (so "test": "jest" finds the local jest) and the package's env exposed as npm_package_*. npm test, npm start, npm run build are conventions; pre<name>/post<name> scripts run around a script automatically (pretest, postinstall). Lifecycle scripts — prepare (runs on local npm install and before publish), postinstall (runs after anyone installs your package) — are powerful and a security concern: a compromised package's postinstall runs arbitrary code on every developer's machine and CI. npx <pkg> runs a package's binary, downloading it if needed.
Publishing, in one paragraph
npm version patch|minor|major bumps and tags; npm publish uploads what files/.npmignore allow (check with npm pack --dry-run); scoped packages publish private by default (--access public). Never publish secrets; never publish node_modules; use "private": true on applications.
Supply chain
Installing a package runs its install scripts and gives its code full access to your process, files and network. Defences: a committed lockfile and npm ci; npm audit (with judgement — many findings are irrelevant devDependency noise); --ignore-scripts where possible; fewer dependencies (a left-pad is a liability, not a saving); pinning in applications, ranges in libraries; provenance/attestations and 2FA for maintainers. Typosquatting (lodahs) and dependency confusion (a public package shadowing a private name) are real attacks — scoped names and registry configuration mitigate them.
Monorepos and workspaces
"workspaces": ["packages/*"] lets one repository hold several packages that depend on each other by name; npm symlinks them into the root node_modules so resolution finds the local source. Tools (Turborepo, Nx, pnpm) add task orchestration and caching on top.
Common mistakes
- Runtime dependencies in
devDependencies(works locally, breaks in production installs with--omit=dev). - No lockfile, or
npm installin CI instead ofnpm ci. */latestranges; relying on a0.xpackage as if^protected you.- Deep imports into a package's internals (
lodash/internal/…) —exportsexists to stop this. - Publishing without
files/private;postinstallscripts you do not need. - Two copies of a package in the tree causing
instanceoffailures or duplicated state (checknpm ls <pkg>).
Interview angle
- "
dependenciesversusdevDependenciesversuspeerDependencies?" Runtime for consumers / development only / must be provided by the host. - "How does
require('x')findx?" Core module, else relative path with extension and index probing, else walk upnode_modulesdirectories, honouringexports/main. - "
^1.2.3versus~1.2.3?" Same major (minor and patch may rise) versus same minor (patch may rise);^0.xbehaves like~. - "Why commit the lockfile?
npm ci?" Reproducible installs — exact versions and hashes;ciinstalls exactly that or fails. - "What is the
exportsfield for?" Defining the package's public entry points with conditions, blocking deep imports.
Key takeaways
package.json:name/version,type,main/exports/bin,scripts, the three dependency kinds,engines,files/private.- Resolution: core → relative (extensions,
index.js) → walk upnode_modules;exportsgates the public surface. - Semver
^(major),~(minor),0.xis unstable; lockfile +npm cifor reproducibility. - Scripts get
.binon PATH;pre/postand lifecycle scripts run automatically — a supply-chain risk. - Fewer dependencies, audited, locked, scoped;
npm lsto find duplicates.
Common questions
What is the difference between dependencies, devDependencies and peerDependencies?
dependencies are needed at run time by whoever installs the package; devDependencies are needed only to develop it, such as a compiler or linter, and are not installed for consumers; peerDependencies must be provided by the host project, the way a React component library expects the app's own React.
What is the difference between caret and tilde in package.json?
A caret range ^1.2.3 accepts any version with the same major number, so minor and patch releases may rise, up to but not including 2.0.0. A tilde range ~1.2.3 accepts only patch releases within 1.2. For 0.x versions the caret behaves like the tilde, because zero-major versions promise no compatibility.
What is the difference between npm install and npm ci?
npm install respects the lockfile where it can but updates it when package.json has changed. npm ci installs exactly what package-lock.json records or fails, deleting node_modules first, which makes it the right command for CI and deployments. Commit the lockfile so every install produces the same program.
How does require find a package in node_modules?
A bare name such as require("lodash") is looked up in ./node_modules, then ../node_modules, and so on up to the filesystem root. Inside the package directory Node honours the exports field if present, otherwise main, otherwise index.js. Core modules like fs are checked first, and relative paths skip the walk entirely.
What is the exports field in package.json?
The exports field defines a package's public entry points and blocks imports of any other file inside it. It also supports conditions such as import, require, node, types and default, so one package can offer an ES module build to import and a CommonJS build to require.
Exercises
A semver range matcher
Implement satisfies(version, range) for the ranges npm users type: an exact version; ^x.y.z (same major; for ^0.y.z same minor; for ^0.0.z exact); ~x.y.z (same minor); comparators >=, >, <=, <; wildcards 1.x, 1.2.x, *; and space-separated parts that must all hold. Commands: check <version> <range…> prints <range> vs <version>: <bool>; max <range> <versions…> prints max <range>: <highest satisfying version or none>.
Example (excerpt):
^1.2.3 vs 1.9.0: true
^1.2.3 vs 2.0.0: false
~1.2.3 vs 1.3.0: false
^0.2.3 vs 0.3.0: false
>=1.2.0 <2.0.0 vs 1.999.0: true
max ^1.0.0: 1.10.0
1.10.0 beats 1.4.2 — compare numerically, never as strings.
Audit a package.json
Read a package.json from stdin and print a report: name=<name or (missing)> version=<version if valid semver else invalid>; type=<type or commonjs (default)> entry=<exports | main <main> | index.js (implicit)>; scripts=<sorted names or none>; deps=<n> devDeps=<n> peerDeps=<n>; unpinned: <sorted names whose range is *, x or latest, or none>; and warnings: <joined by "; " or none> for: no engines.node; a postinstall script; publishable (private not true) without a files list; a package listed in both dependencies and devDependencies.
Example (a library with several problems) →
name=@acme/tools version=2.3.1
type=module entry=exports
scripts=build,postinstall,test
deps=2 devDeps=2 peerDeps=1
unpinned: express,left-pad
warnings: no engines.node; postinstall script runs on every install; publishable without a files allow-list; listed in both dependencies and devDependencies: expressIn this module: Modules and Node
- CommonJS and ES modules — two module systems, one runtime
- Packages, package.json, npm and semver (this lesson)
- The core modules you use every week — fs, path, os, process, util, events, url, crypto
- Buffers and streams — bytes, and data that does not fit in memory
- The process — arguments, environment, exit codes, signals, child processes and workers
- Checkpoint — Modules and Node
← CommonJS and ES modules — two module systems, one runtime · The core modules you use every week — fs, path, os, process, util, events, url, crypto →