AI InterviewSearch…Search problems, topics, or pages…Ctrl KGet started
Skip to content

09 Modules and Node / Lesson 2 of 5

Packages, package.json, npm and semver

12 min · +10 XP · 2 exercises · 5 questions

Read the text12 min
Exercises0/2
Quiz5 q

Quick answer

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.

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

  1. Core module? (fs, node:fs) → built in.
  2. Relative or absolute (./, ../, /) → try the exact path, then .js, .json, .node; then as a directory: its package.json main, then index.js.
  3. 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, honour exports if present (only the listed subpaths are allowed), else main, else index.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:

RangeMeansMatches
1.2.3exactly1.2.3
^1.2.3same major, ≥ 1.2.3 (default for npm install)1.2.3 … 1.99.99, not 2.0.0
^0.2.3for 0.x: same minor0.2.3 … 0.2.99
~1.2.3same minor, ≥ 1.2.31.2.3 … 1.2.99
>=1.2.0 <2.0.0explicit
1.x / 1.*any 1.y.z
* / latestanythingnever 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 install in CI instead of npm ci.
  • */latest ranges; relying on a 0.x package as if ^ protected you.
  • Deep imports into a package's internals (lodash/internal/…) — exports exists to stop this.
  • Publishing without files/private; postinstall scripts you do not need.
  • Two copies of a package in the tree causing instanceof failures or duplicated state (check npm ls <pkg>).

Interview angle

  • "dependencies versus devDependencies versus peerDependencies?" Runtime for consumers / development only / must be provided by the host.
  • "How does require('x') find x?" Core module, else relative path with extension and index probing, else walk up node_modules directories, honouring exports/main.
  • "^1.2.3 versus ~1.2.3?" Same major (minor and patch may rise) versus same minor (patch may rise); ^0.x behaves like ~.
  • "Why commit the lockfile? npm ci?" Reproducible installs — exact versions and hashes; ci installs exactly that or fails.
  • "What is the exports field 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 up node_modules; exports gates the public surface.
  • Semver ^ (major), ~ (minor), 0.x is unstable; lockfile + npm ci for reproducibility.
  • Scripts get .bin on PATH; pre/post and lifecycle scripts run automatically — a supply-chain risk.
  • Fewer dependencies, audited, locked, scoped; npm ls to 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.

Mark as readSign in to keep your place.

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.

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

  1. Core module? (fs, node:fs) → built in.
  2. Relative or absolute (./, ../, /) → try the exact path, then .js, .json, .node; then as a directory: its package.json main, then index.js.
  3. 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, honour exports if present (only the listed subpaths are allowed), else main, else index.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:

RangeMeansMatches
1.2.3exactly1.2.3
^1.2.3same major, ≥ 1.2.3 (default for npm install)1.2.3 … 1.99.99, not 2.0.0
^0.2.3for 0.x: same minor0.2.3 … 0.2.99
~1.2.3same minor, ≥ 1.2.31.2.3 … 1.2.99
>=1.2.0 <2.0.0explicit
1.x / 1.*any 1.y.z
* / latestanythingnever 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 install in CI instead of npm ci.
  • */latest ranges; relying on a 0.x package as if ^ protected you.
  • Deep imports into a package's internals (lodash/internal/…) — exports exists to stop this.
  • Publishing without files/private; postinstall scripts you do not need.
  • Two copies of a package in the tree causing instanceof failures or duplicated state (check npm ls <pkg>).

Interview angle

  • "dependencies versus devDependencies versus peerDependencies?" Runtime for consumers / development only / must be provided by the host.
  • "How does require('x') find x?" Core module, else relative path with extension and index probing, else walk up node_modules directories, honouring exports/main.
  • "^1.2.3 versus ~1.2.3?" Same major (minor and patch may rise) versus same minor (patch may rise); ^0.x behaves like ~.
  • "Why commit the lockfile? npm ci?" Reproducible installs — exact versions and hashes; ci installs exactly that or fails.
  • "What is the exports field 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 up node_modules; exports gates the public surface.
  • Semver ^ (major), ~ (minor), 0.x is unstable; lockfile + npm ci for reproducibility.
  • Scripts get .bin on PATH; pre/post and lifecycle scripts run automatically — a supply-chain risk.
  • Fewer dependencies, audited, locked, scoped; npm ls to 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: express

In this module: 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 →