Modules, npm & Semantic Versioning

Open any serious JavaScript project and the first thing you notice is that almost none of it is code you wrote. There’s a src/ folder with your files, and next to it a node_modules/ folder with tens of thousands of files you’ve never read, pulled from hundreds of packages you didn’t publish. Somehow that pile boots up as one coherent program.

The machinery that makes that work is small and worth understanding completely: a module system that says how files reference each other, a manifest (package.json) that describes each package, a registry you download from, a lockfile that makes the download reproducible, and a versioning convention so upgrades don’t silently break you. Get these five right and dependency management stops being scary. Get them fuzzy and you’ll spend afternoons debugging why the same code runs on your laptop and dies in CI.

Let’s wire it up from the inside out.

Modules, at the ecosystem level

You already know how to split code across files: export a value from one, import it into another. At the scale of a whole project, the thing that matters is which module system your files speak, because there are two and they don’t blend cleanly.

ES modules (ESM) is the standardized one — part of the language, the same syntax in browsers and in Node.

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

// app.mjs
import mul, { add } from './math.mjs';

CommonJS (CJS) is the older Node-only system, built before the language had modules of its own. It uses require and module.exports:

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

// app.cjs
const { add } = require('./math.cjs');

The two differ in more than spelling. ESM imports are static — resolved before any code runs, which is what lets bundlers tree-shake unused exports away. CJS require is a function call that runs during execution, so it’s dynamic but opaque to static analysis. ESM is asynchronous-friendly and supports top-level await; CJS is strictly synchronous.

ESM — static, resolved up frontimport { add }from ‘./math’live binding to addmul never referencedmul droppedCJS — dynamic, runs at executionconst m =require(‘./math’)whole module objectbundler can’t tell what’s usedeverything kept
ESM bindings are resolved before evaluation, so a bundler can see exactly which exports are used and drop the rest. CJS require runs at execution time and returns whatever the module object happens to hold.

Telling Node which one a file is

Node decides a file’s module type from two signals:

  • The extension. A .mjs file is always ESM; a .cjs file is always CJS.
  • The nearest package.json type field for plain .js files. "type": "module" makes .js mean ESM; "type": "commonjs" (or an absent field) makes .js mean CJS.

So "type": "module" in your package.json is the switch that flips every .js in the project to ESM. New projects in 2026 almost always set it.

The whole ecosystem has converged on ESM-first. New libraries ship ESM as their primary format, browsers run it natively, and every bundler treats it as the default. CommonJS isn’t dead — billions of installs depend on it — but it’s now the compatibility layer, not the destination. When you start something new, write ESM.

The manifest: package.json

Every package — yours and every dependency — is described by a package.json at its root. It’s the identity card and the wiring diagram in one file.

{
  "name": "@acme/parser",
  "version": "2.4.1",
  "type": "module",
  "exports": {
    ".": "./dist/index.js",
    "./cli": "./dist/cli.js"
  },
  "scripts": {
    "build": "tsc -p .",
    "test": "vitest run"
  },
  "dependencies": {
    "picocolors": "^1.1.0"
  },
  "devDependencies": {
    "vitest": "^3.0.0",
    "typescript": "^5.7.0"
  },
  "peerDependencies": {
    "react": ">=18"
  }
}
identity
name — registry-unique, may be scoped like @acme/x
version — semver, one per publish
type — module or commonjs
entry points
exports — the public API map (preferred)
main / module — legacy single entries
bin — CLI executables
edges + tasks
dependencies — needed at runtime
devDependencies — build/test only
peerDependencies — host must supply
scripts — named commands
The fields of package.json split into three jobs: identity, entry points, and the three kinds of dependency edge.

The three kinds of dependency

The split matters more than beginners expect, because it decides what ends up in a production install.

  • dependencies — packages your code imports and needs when it actually runs. A date library, an HTTP client. These get installed for anyone who installs you.
  • devDependencies — tools you need to build and test but that never ship: the test runner, the type checker, the bundler. When someone installs your package as a dependency, these are skipped entirely.
  • peerDependencies — a contract that says “I plug into your copy of this.” A React component library declares react as a peer so it uses the host app’s single React instance instead of bundling a second one. The consumer is responsible for installing a compatible version.

scripts are named shell commands you run with npm run build, pnpm test, and so on. A few names are special — test, start, and lifecycle hooks like prepublishOnly — but most are just aliases you invent.

The exports field: your package’s front door

For most of npm’s history a package’s entry point was one line, "main": "./index.js", and every file inside the package was reachable by deep path. If you shipped ./src/internal/secret.js, a consumer could import it, and now that private file is part of your API forever.

The exports field fixes this. It’s a map from the paths consumers are allowed to import to the real files on disk — and crucially, it’s an allowlist. Anything not listed is unreachable, even if the file exists.

{
  "exports": {
    ".": "./dist/index.js",
    "./utils": "./dist/utils.js",
    "./package.json": "./package.json"
  }
}

With this map, import x from '@acme/parser' resolves to dist/index.js, and import u from '@acme/parser/utils' resolves to dist/utils.js. But import '@acme/parser/dist/internal/secret.js' now throws — that path isn’t in the map, so it doesn’t exist as far as consumers are concerned.

consumer importsexports gate‘@acme/parser’‘@acme/parser/utils’‘…/dist/secret.js’exports“.” ✓“./utils” ✓(no deeppaths listed)dist/index.jsdist/utils.jsblocked — throwsListed subpaths map to real files. Unlisted paths resolve to nothing — internals stay internal.
The exports map is an allowlist. Listed subpaths resolve to real files; anything unlisted is blocked, so internal files stay private.

Conditional exports

The map values can branch on how the package is loaded. This is how one package ships both an ESM and a CommonJS build, plus the right TypeScript types for each:

{
  "exports": {
    ".": {
      "types": "./dist/index.d.ts",
      "import": "./dist/index.mjs",
      "require": "./dist/index.cjs",
      "default": "./dist/index.mjs"
    }
  }
}

Node reads these conditions top to bottom and picks the first that applies. An import statement takes the import branch; a require() call takes require; a bundler for the browser might match a browser condition if you add one. Order matters — put the most specific conditions first and always end with default.

Installing: registries, npm, and pnpm

A registry is a server that hosts published packages; the default is the public npm registry. A package manager is the client that talks to it, resolves your dependency ranges into concrete versions, downloads the tarballs, and lays them out in node_modules/.

Three clients dominate:

  • npm — bundled with Node, the baseline everyone has.
  • pnpm — faster, far more disk-efficient, strict about which packages you can import. The common choice for new and large projects in 2026.
  • Yarn — the pioneer of lockfiles and workspaces; still widely used, especially older Yarn-Classic and the newer Yarn Berry with its Plug’n’Play mode.

They read the same package.json and mostly agree on results. Where they differ is how they lay out node_modules and how strictly they enforce the dependency graph — and that difference is worth seeing.

The lockfile

Your package.json records ranges ("picocolors": "^1.1.0" means “some 1.x at or above 1.1.0”). Ranges are deliberately loose — they let you pick up bug fixes. But loose is the enemy of reproducibility: install today and you might get 1.1.0; install next week and get 1.1.4, with a subtle behavior change you never asked for.

The lockfile (package-lock.json for npm, pnpm-lock.yaml for pnpm, yarn.lock for Yarn) closes that gap. After resolving ranges once, the package manager writes down the exact version of every package in the entire tree — including your dependencies’ dependencies — plus a cryptographic integrity hash of each tarball. The next install reads the lockfile and reproduces that exact tree, byte for byte.

package.json — rangesapp ^2.0.0parser ^2.4.0colors ^1.1.0resolver picksparser 2.4.1colors 1.1.3tslib 2.8.0 (transitive)lockfile — pinnedparser 2.4.1sha512-Qx…colors 1.1.3sha512-9f…tslib 2.8.0sha512-a1…exact + integrityCommit the lockfile. npm ci / pnpm i –frozen-lockfile install strictly from it — no re-resolution.That’s what makes your machine, your teammate’s, and CI produce the identical tree.
package.json declares loose ranges. The resolver picks concrete versions across the whole transitive tree, and the lockfile pins each one with an integrity hash so every install is identical.

How the packages land on disk

Here npm and pnpm diverge sharply. npm builds a flattened node_modules: it hoists most packages to the top level and copies files per project. Install the same library in ten projects and you have ten copies on disk. Flattening also means a package can import something it never declared, just because a different dependency happened to hoist it to the top — a “phantom dependency” that works until the day it doesn’t.

pnpm takes a different route. Every version of every package is stored once, globally, in a content-addressed store on your disk. Files in your project’s node_modules are hard links into that store — no copying, near-zero extra space. The visible layout is built from symlinks: only your declared dependencies appear at the top of node_modules, and each package’s own dependencies are linked in beside it. If you didn’t declare it, you can’t import it. Phantom dependencies simply can’t happen.

npm — copied & flattenedprojectA/node_modules[email protected] (full copy)[email protected] (full copy)projectB/node_modules[email protected] (another copy)same version, duplicated per project → disk growspnpm — one store, linked in~/.pnpm-store (global)[email protected][email protected]projectA/node_modulescolors → store (hard link)parser → store (hard link)projectB/node_modulescolors → same store fileno extra disk used
npm copies each package into every project's flattened tree. pnpm stores each version once in a global content-addressed store and hard-links it in, with symlinks recreating the real dependency graph.

Workspaces and monorepos

A workspace is one repository holding many packages that depend on each other — a shared UI kit, a couple of apps, some internal utilities, each with its own package.json. You declare the members once at the root:

{
  "name": "acme-monorepo",
  "private": true,
  "workspaces": ["packages/*", "apps/*"]
}

Now one install at the root resolves and links all of them together. When apps/web depends on @acme/ui, the package manager symlinks your local packages/ui straight into apps/web/node_modules — no publishing, no version juggling. Edit the UI kit, the app sees the change immediately. pnpm and Yarn have first-class workspace support; npm supports it too. Tools like Turborepo or Nx sit on top to cache and orchestrate builds across the members.

Semantic versioning

A version like 2.4.1 isn’t arbitrary. Under semantic versioning (semver) it’s three numbers with agreed meanings, and the whole range system depends on maintainers honoring them.

2 . 4 . 1MAJORMINORPATCHbreaking changeconsumers must adaptnew featurebackward compatiblebug fixno API change
Semver encodes the kind of change in the number itself: patch for fixes, minor for backward-compatible features, major for breaking changes.
  • MAJOR bumps when you make a breaking change — removing a function, renaming an option, changing what a call returns. Consumers may have to edit code to upgrade.
  • MINOR bumps when you add functionality that existing code keeps working through. New optional parameter, new export.
  • PATCH bumps for bug fixes that change nothing about the interface.

A “breaking change” is defined from the consumer’s side: if code that worked on the old version could stop working on the new one, it’s breaking, full stop — even a “small” behavior tweak.

Ranges: ^, ~, and exact

The prefix in your package.json tells the resolver how far it’s allowed to move. The two you’ll see everywhere are caret and tilde.

1.2.31.2.91.5.01.9.92.0.0^1.2.3any 1.x ≥ 1.2.3, up to (not incl.) 2.0.0~1.2.31.2.x only1.2.3exactly 1.2.3, pinnedBoth allow the fixes maintainers ship. Caret also accepts new features; exact accepts nothing.
Caret allows changes up to the next major; tilde allows only patch changes within the current minor; no prefix pins one exact version.
  • ^1.2.3 (caret) — allow anything that doesn’t change the leftmost non-zero digit. For a 1.x package that’s “any 1.x at or above 1.2.3, below 2.0.0.” This is npm’s default when you npm install a-package.
  • ~1.2.3 (tilde) — allow patch updates only: >= 1.2.3 and < 1.3.0. Tighter; picks up fixes but not new features.
  • 1.2.3 (no prefix) — exactly that version. Nothing moves.

Versions can also carry a prerelease tag: 2.0.0-beta.1, 3.1.0-rc.2. These sort before the final release (2.0.0-beta.1 < 2.0.0) and are opt-in — a normal range like ^1.0.0 will not pull in a prerelease unless you ask for one explicitly. On the registry, prereleases usually sit behind a dist-tag like next or beta instead of latest, so npm install pkg@next gets you the bleeding edge while npm install pkg stays on stable.

Publishing and supply-chain hygiene

Shipping a package is short: bump the version, then npm publish (scoped packages need --access public the first time). What deserves your attention is everything around that command, because installing dependencies means running code from strangers.

Control what actually ships. By default the tarball includes your whole directory minus a few ignored paths. Set a "files" allowlist in package.json (or a .npmignore) so you publish dist/ and nothing else — no source, no secrets, no .env that slipped in. Run npm publish --dry-run first to see the exact file list.

Audit and pin. npm audit cross-checks your locked tree against a vulnerability database and reports advisories with a severity and, often, a fixed version. Treat it as a signal, not gospel — noisy transitive warnings are common — but wire it into CI so a critical advisory can fail the build. For anything that runs in production, lean toward tighter ranges and always commit the lockfile.

Verify provenance. Modern npm supports provenance: when a package is published from a CI pipeline (GitHub Actions, say) with the right settings, it’s cryptographically signed via Sigstore and recorded in a public transparency log that ties the published bytes to a specific source commit and build. You can check the signatures on your installed tree with npm audit signatures. The important caveat, as of 2026: provenance is not enforced at install time. It’s a verification tool you opt into, not a gate that stops an unsigned or tampered package from installing. Use it, but don’t assume it’s protecting you automatically.

Summary

  • Write ESM. It’s the language standard, static (so bundlers can tree-shake), and the ecosystem’s default in 2026. CommonJS is now the compatibility layer, and require() of a synchronous ES module is finally stable across current Node LTS lines — but a module with top-level await still can’t be require()d.
  • package.json is the manifest. name/version/type are identity; exports defines entry points; and the split between dependencies, devDependencies, and peerDependencies decides what ships, what’s build-only, and what the host must provide.
  • exports is an allowlist. Listed subpaths resolve to real files; unlisted ones are unreachable, keeping internals private. Conditional exports (import/require/types/default) let one package serve ESM and CJS consumers — mind the ordering and remember to expose ./package.json.
  • Lockfiles buy reproducibility. package.json holds loose ranges; the lockfile pins exact versions plus integrity hashes for the whole transitive tree. Commit it; install with npm ci in CI.
  • npm copies and flattens; pnpm links. pnpm stores each version once in a content-addressed store, hard-links files in, and uses symlinks to recreate the true graph — less disk, and no phantom dependencies.
  • Semver is a promise. MAJOR breaks, MINOR adds, PATCH fixes. ^ moves up to the next major (with special zero-major rules), ~ allows only patches, no prefix pins exactly, and prereleases stay opt-in.
  • Treat installs as running strangers’ code. Control the published file list, audit and pin, and verify provenance with npm audit signatures — while knowing it isn’t enforced at install time.