Modules & npm

Difficulty

Answer:

CommonJS (CJS) — Node's original module system:

// math.js
function add(a, b) { return a + b; }
module.exports = { add };

// app.js
const { add } = require('./math');
  • Synchronous: require loads and executes the module immediately.
  • Dynamic: you can require conditionally, inside functions, with computed paths.
  • Exports are a copy of the reference at export time.

ES Modules (ESM) — the JavaScript standard:

// math.mjs
export function add(a, b) { return a + b; }

// app.mjs
import { add } from './math.mjs';
  • Static: imports/exports are resolved before execution, enabling tree-shaking and better tooling.
  • Asynchronous loading; supports top-level await.
  • Exports are live bindings — importers see updated values if the exporting module changes them.
  • Import paths generally need the file extension.

How Node picks the system:

  • .cjs → always CommonJS; .mjs → always ESM.
  • .js → depends on the nearest package.json: "type": "module" means ESM, "type": "commonjs" (or absent) means CJS.

Interop:

  • ESM can import CommonJS modules (the module.exports becomes the default export).
  • CommonJS cannot require an ESM module synchronously; use dynamic await import().
  • __dirname/__filename don't exist in ESM — use import.meta.url.

Interview tip: The core distinction is static + async (ESM) vs dynamic + synchronous (CJS). Static structure is what enables tree-shaking.

Answer:

Resolution algorithm (roughly):

  1. Core module? e.g. require('fs') → return the built-in.
  2. Relative/absolute path? ./, ../, / → resolve the file, trying extensions .js, .json, .node, then a directory's index.js or its package.json main.
  3. Bare specifier? e.g. require('express') → look in ./node_modules, then the parent directory's node_modules, walking up to the filesystem root.
require('fs');          // core
require('./utils');     // ./utils.js or ./utils/index.js
require('lodash');      // node_modules/lodash

Caching:

  • After a module runs, its module.exports is stored in require.cache, keyed by the resolved absolute path.
  • Every later require of that path returns the same object — the module body runs only once.
// counter.js
let count = 0;
module.exports = { inc: () => ++count, get: () => count };

// a.js and b.js both require('./counter') share the SAME instance

This is the basis of the singleton pattern in Node and why module top-level code (like a DB connection) runs a single time.

Cache gotchas:

  • Two different resolved paths (e.g., two copies of a package in different node_modules) are separate cached instances — a common source of "instanceof fails" or "duplicate singleton" bugs.
  • You can delete require.cache[resolvedPath] to force a reload (used by some hot-reload tools), but it's rarely a good idea in production.

Answer: package.json is the manifest at the root of a Node project.

Common fields:

{
  "name": "my-api",
  "version": "1.4.2",
  "type": "module",
  "main": "dist/index.js",
  "exports": { ".": "./dist/index.js" },
  "scripts": {
    "start": "node dist/index.js",
    "dev": "node --watch src/index.js",
    "test": "jest"
  },
  "dependencies": { "express": "^4.19.2" },
  "devDependencies": { "jest": "^29.7.0" },
  "engines": { "node": ">=18" }
}
  • main / exports — entry points (exports is the modern, stricter form controlling what's importable).
  • scripts — commands runnable via npm run <name> (start and test also work without run).
  • engines — declares the supported Node version.

Semantic Versioning (semver): MAJOR.MINOR.PATCH

  • MAJOR — breaking changes.
  • MINOR — new, backward-compatible features.
  • PATCH — backward-compatible bug fixes.

Range specifiers:

RangeAllowsExample matches for 1.4.2
^1.4.2minor + patch (no major)1.4.2<2.0.0
~1.4.2patch only1.4.2<1.5.0
1.4.2exactonly 1.4.2
* / latestanythingany version

^ is the npm default because it gets bug fixes and features without (in theory) breaking changes. The exact installed versions are pinned in the lockfile.

Answer:

dependencies — packages your code needs to run in production (e.g., express, pg). Installed by default for anyone using your package.

devDependencies — packages needed only during development/build/test (e.g., jest, eslint, typescript, bundlers). Skipped when installing with npm install --omit=dev (formerly --production), which keeps production images lean.

peerDependencies — a compatible version your package expects the host to provide, rather than bundling its own copy. Typical for plugins/libraries that must share a single instance with the host:

// a React component library
"peerDependencies": { "react": ">=17" }

This avoids two copies of React and the "invalid hook call" class of bugs.

optionalDependencies — installs are attempted but failures don't abort (e.g., platform-specific binaries).

Lockfile (package-lock.json, or yarn.lock/pnpm-lock.yaml):

  • Records the exact version and integrity hash of every package in the entire tree (including transitive deps).
  • Guarantees reproducible installs — everyone and every CI machine gets the identical tree, regardless of when they install.
  • Commit it. Use npm ci in CI, which installs strictly from the lockfile and fails if package.json and the lock disagree — faster and deterministic.

Interview tip: package.json says what ranges are acceptable; the lockfile says what was actually installed.

Answer:

npm scripts: Defined under scripts in package.json and run via npm run <name>:

"scripts": {
  "start": "node index.js",
  "dev": "nodemon src/index.js",
  "test": "jest",
  "lint": "eslint src",
  "build": "tsc"
}
  • Run with npm run dev. start and test are special and also work as npm start / npm test.
  • During a script, node_modules/.bin is added to PATH, so you can call locally-installed tools (jest, eslint, tsc) by name without a global install.
  • Lifecycle hooks: a script named pre<name> runs before, and post<name> runs after (e.g., pretest runs before test).

npx: npx executes a package binary without a permanent global install:

npx create-react-app my-app   # download + run a scaffolder once
npx eslint .                  # run the locally installed eslint
npx cowsay hello              # try a tool without installing it

Resolution order: a matching binary in the local node_modules/.bin first, otherwise npx downloads the package to a temporary cache and runs it.

Why it matters:

  • Avoids polluting the global namespace and version drift from globally installed CLIs.
  • Ensures you run the project's pinned version of a tool rather than whatever is installed globally.