Skip to content
System nominalUTC --:--:--Articles 041
MODULE 03 Lesson 09 / 18 35 MIN

Functions, closures and modules

ES Modules, npm and Dependency Hygiene

Named exports, live bindings, dynamic import and CommonJS interop, plus the package.json fields and semver ranges that decide what your next install actually gets.

Modules are where JavaScript mistakes survive longest. A default export reads fine on the day you write it and turns every rename into a search across three packages. A caret range installs something different on your laptop than in CI, and nothing fails until the deploy that matters. Neither problem throws an error where you can see it.

Two problems are tangled together. One is the module system: how exports are named, what an imported binding means, and when code loads. The other is the package manager on top, which decides which version of a dependency your node_modules contains. A project can be right about one and wrong about the other. The examples ran on Node 24.11.1 against a hand-built node_modules, so the output needs no network.

Named exports survive refactoring and default exports do not

A module exports as many named things as it likes, and one default. Importers ask for named exports by name:

// src/format.mjs
export const CURRENCY = "EUR";
export let locale = "de-DE";

export function money(amount) {
  return new Intl.NumberFormat(locale, { style: "currency", currency: CURRENCY }).format(amount);
}

export function setLocale(next) {
  locale = next;
}

export default function moneyShort(amount) {
  return `${amount.toFixed(2)} ${CURRENCY}`;
}
// src/main.mjs
import short, { money, locale, setLocale } from "./format.mjs";

console.log("money:", money(1234.5));
setLocale("fr-FR");
console.log("money now:", money(1234.5));
console.log("default:", short(2));
money: 1.234,50 €
money now: 1 234,50 €
default: 2.00 EUR

The default export has no name of its own, and that is the whole problem. moneyShort exists inside the module and nowhere else, so every importer invents a label. Across a package the same function is called short, formatMoney and fmt in different files, and when you rename it the tooling cannot find the callers, because no caller ever wrote the name down. Your editor renames a named export across a workspace; for a default it can only guess. A default can also be renamed at the import site with no error, so a change of meaning hides behind a change of alias:

import short from "./format.mjs";        // the same function
import moneyShort from "./format.mjs";   // still the same function

My rule is one default per package, and only when the package exists to provide one obvious thing. Defaults do have one advantage: module.exports = fn maps to a default, so they are convenient for CommonJS interop. That justifies one on a package whose entry point is a single function, not on a utility module.

An imported name is a live read-only binding

When you write import { locale }, you do not get a copy of the value at the moment of import. You get a binding to the exporting module's own binding. Reassign it in the exporter and every importer sees the new value, with no re-import and no getter written by hand. The output above shows it: setLocale("fr-FR") changed the formatting in a later money() call in the importing file.

The binding is read-only to the importer, and the error depends on how you try to write it:

import * as fmt from "./format.mjs";
import { locale } from "./format.mjs";

locale = "en-GB";
// TypeError: Assignment to constant variable.

fmt.locale = "en-GB";
// TypeError: Cannot assign to read only property 'locale' of object '[object Module]'

Both are TypeErrors, and the second tells you what a module namespace object is: a read-only view of the exporting module's bindings. That is why the old spy trick of importing a namespace and overwriting a function on it fails.

The graph is analysed before anything runs, so a name that does not exist is a link error rather than a late undefined:

import { nope } from "./format.mjs";
SyntaxError: The requested module './format.mjs' does not provide an export named 'nope'

That is the practical argument for ES modules in new Node code. CommonJS require resolves a missing property to undefined and fails later at the call. The price is a static graph: every import resolves before evaluation, in dependency order, and the syntax has no conditional import.

Dynamic import splits code and postpones evaluation

import() returns a promise for the module namespace. It can appear anywhere, take a computed specifier, and evaluates the module then rather than at startup:

// src/dynamic.mjs
const name = process.argv[2] ?? "lazy";

console.log("before import");
const mod = await import(`./${name}.mjs`);
console.log("after import:", Object.keys(mod));
console.log("call:", mod.heavy());
before import
lazy.mjs evaluated
after import: [ 'heavy' ]
call: expensive result

The order of that output is the point. The imported module is evaluated inside the await, so anything it does at import time happens then. In a bundler the module lands in a separate chunk fetched when the promise runs, which is how a heavy editor stays out of the initial payload, and it allows conditional loading by environment or feature check. The promise rejects with a code that says whether the module was missing or threw while evaluating:

ERR_MODULE_NOT_FOUND: Cannot find module '/tmp/app/nope-does-not-exist.mjs' imported from /tmp/app/[eval]

Two cautions. The catch is not optional, because an unhandled rejection can terminate a Node process. And a computed specifier is opaque to bundlers: Node resolves it against the file system, a browser bundler cannot enumerate what might load, so prefer a static map there.

CommonJS still works, and the default export is module.exports

Most packages you install are CommonJS, so the interop rules matter. Importing one from an ES module gives you a namespace whose default is module.exports, plus named exports when the static analyser can see them:

// src/legacy.cjs
const VERSION = "1.4.0";
function slugify(text) { return text.toLowerCase().replace(/[^a-z0-9]+/g, "-"); }
module.exports = { slugify, VERSION };
module.exports.extra = "added later";
import legacy from "./legacy.cjs";
console.log(legacy.slugify("Hello World"), legacy.VERSION);

const ns = await import("./legacy.cjs");
console.log("namespace keys:", Object.keys(ns));
console.log("ns.default === legacy:", ns.default === legacy);
hello-world 1.4.0
namespace keys: [ 'VERSION', 'default', 'extra', 'module.exports', 'slugify' ]
ns.default === legacy: true

Node's loader reads the CommonJS source and infers the named exports, which is why VERSION and slugify appear alongside default. The inference is best effort, so when in doubt import the default and destructure from it:

import legacy from "./legacy.cjs";
const { slugify, VERSION } = legacy;

The other direction changed recently enough that older advice is wrong. Calling require() on an ES module used to throw ERR_REQUIRE_ESM; on the Node 24.11.1 release used here it works and returns the frozen namespace, and a module using top-level await cannot be loaded that way at all. If a library is consumed from both sides, ship a CommonJS entry for the require condition rather than relying on interop.

exports in package.json decides what importers may reach

The exports field maps a specifier to a file or a condition, and once a package declares it, that map is the complete list of what consumers can import:

{
  "name": "widgetly",
  "version": "2.3.4",
  "type": "module",
  "main": "./lib/index.js",
  "exports": {
    ".": {
      "import": "./lib/index.js",
      "require": "./lib/index.cjs"
    },
    "./plugin": "./lib/plugin.js",
    "./package.json": "./package.json"
  },
  "engines": { "node": ">=22" },
  "files": ["lib"]
}
import { api } from "widgetly";              // root: esm entry
await import("widgetly/plugin");             // subpath: true

await import("widgetly/lib/internal.js");
// ERR_PACKAGE_PATH_NOT_EXPORTED
// Package subpath './lib/internal.js' is not defined by "exports" in .../widgetly/package.json

require("widgetly");                         // cjs got: cjs entry

Three fields do different jobs. main is the old single-entry pointer, kept for tools that predate exports, which wins wherever it is present. Its conditions are matched in order against the loader doing the asking, which is how one specifier resolves to an ES module for import and a CommonJS file for require, as the last line of that transcript shows. type changes behaviour at runtime: it sets how .js files are read, with "module" making them ES modules and the .mjs and .cjs extensions overriding it either way. Get it wrong and the error names the fix:

Warning: Failed to load the ES module: /tmp/app/src/type-test.cjs.
Make sure to set "type": "module" in the nearest package.json file or use the .mjs extension.

engines is a declaration, not an enforcement. I set "engines": { "node": ">=22" } on everything I publish, because npm reports a mismatch to the installer and CI systems read it, but Node ignores it at runtime. If a check must happen, compare process.versions.node in your own code. For an application I skip exports and set "private": true, since encapsulation matters only to consumers and there are none.

A caret range is a promise about the next install, not about the version

A semver range describes which versions are acceptable, and the caret has one rule with an exception that catches people on small numbers. ^1.2.3 allows anything at or above 1.2.3 below 2.0.0; ^0.2.3 allows 0.2.x only, because below 1.0 the minor number is the breaking one; ^0.0.3 allows exactly 0.0.3. These are npm's semantics, taken from the semver package:

const semver = require("semver");

const cases = [
  ["^1.2.3", ["1.2.3", "1.2.4", "1.3.0", "1.9.9", "2.0.0", "1.2.2", "0.9.0"]],
  ["^0.2.3", ["0.2.3", "0.2.9", "0.3.0", "0.2.2"]],
  ["^0.0.3", ["0.0.3", "0.0.4", "0.1.0"]],
  ["~1.2.3", ["1.2.3", "1.2.9", "1.3.0"]],
  ["1.2.x", ["1.2.0", "1.2.9", "1.3.0"]],
];

for (const [range, versions] of cases) {
  const ok = versions.filter((v) => semver.satisfies(v, range));
  const no = versions.filter((v) => !semver.satisfies(v, range));
  console.log(range.padEnd(8), "allows:", ok.join(" "), "| rejects:", no.join(" "));
}
^1.2.3   allows: 1.2.3 1.2.4 1.3.0 1.9.9 | rejects: 2.0.0 1.2.2 0.9.0
^0.2.3   allows: 0.2.3 0.2.9 | rejects: 0.3.0 0.2.2
^0.0.3   allows: 0.0.3 | rejects: 0.0.4 0.1.0
~1.2.3   allows: 1.2.3 1.2.9 | rejects: 1.3.0
1.2.x    allows: 1.2.0 1.2.9 | rejects: 1.3.0

Read the first line slowly, because it is the whole problem. ^1.2.3 admits 1.9.9 and rejects 2.0.0, and the resolver takes the highest version it is allowed. The range is therefore not a promise about the version you get but about the ceiling the resolver may climb to. A dependency author who ships a breaking change in a minor release, which semver forbids and people do anyway, breaks you on an install you thought was safe. The exception catches people on small packages: ^0.2.3 admits 0.2.9 and refuses 0.3.0, tighter than the number suggests if you have only internalised the 1.x rule.

The lockfile pins the version and the package.json pins the range

Lockfiles close that gap: the manifest records the range you asked for, the lockfile the exact version and integrity hash that was resolved.

// package.json
"dependencies": { "semver": "^7.8.5" }

// package-lock.json
"node_modules/semver": {
  "version": "7.8.5",
  "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz",
  "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA=="
}

Install from that pair and you get 7.8.5 even after 7.9.0 ships, because the resolver honours the lockfile. The integrity field is the part people skip: it is a hash of the tarball, so a republished or tampered package fails to install instead of installing quietly. Commit the lockfile even for a library, because it is how your CI reproduces what you tested, but do not expect it to constrain consumers, who resolve against your ranges.

Two habits keep this from rotting. Run npm ci in CI rather than npm install, because ci installs from the lockfile and fails when the manifest disagrees instead of rewriting the lockfile on the build server. And pin a dependency exactly, with no caret, when a surprise minor version would cost you a day.

Lab 35 minutes

Build a two-file package under node_modules by hand, so you control what the resolver sees. Create node_modules/gaugekit/package.json with "name": "gaugekit", "version": "0.4.2", "type": "module", a main pointing at ./lib/index.js, an exports map with a root entry offering import and require conditions plus one subpath ./format, and "engines": { "node": ">=22" }. Write lib/index.js as ES module code, lib/index.cjs as CommonJS, and lib/format.js that the ./format subpath points at. Add a fourth file lib/internal.js that no entry mentions.

Then predict the outcome of each line below before you run it, and write down the error code for the ones that fail.

import { gauge } from "gaugekit";
await import("gaugekit/format");
await import("gaugekit/lib/internal.js");
await import("gaugekit/lib/format.js");
await import("gaugekit");
await import("gaugekit/package.json", { with: { type: "json" } });

If gaugekit is at 0.4.2, what does a dependency declaring "gaugekit": "^0.4.0" permit, and would it accept 0.5.0? And if you deleted the exports map but kept main, which of the six lines above change behaviour?

Show the solution and the reasoning

Here is the package as I would write it. The subpath entry is the only thing a consumer can reach besides the root, and the require condition gives CommonJS callers a real CommonJS file rather than an interop guess.

{
  "name": "gaugekit",
  "version": "0.4.2",
  "type": "module",
  "main": "./lib/index.js",
  "exports": {
    ".": {
      "import": "./lib/index.js",
      "require": "./lib/index.cjs"
    },
    "./format": "./lib/format.js",
    "./package.json": "./package.json"
  },
  "engines": { "node": ">=22" }
}

The six lines resolve like this. The root import works through the import condition, and so does the dynamic one. gaugekit/format works because the map names it. gaugekit/lib/internal.js and gaugekit/lib/format.js both fail with ERR_PACKAGE_PATH_NOT_EXPORTED: with a map a package no longer exposes its files by path, and the exported specifier for that second file is gaugekit/format. The last line returns the package's own package.json, which needs the JSON import attribute and is exposed because tools read its version.

The path-shaped specifiers are the ones people expect to work. Forget the with attribute on the JSON line and the error is ERR_IMPORT_ATTRIBUTE_MISSING: the resolver found the file, the loader refused to guess its type. On the range, ^0.4.0 permits 0.4.x only, so it accepts 0.4.2 and refuses 0.5.0.

Delete exports but keep main and three of the six change. The root import resolves through main to ./lib/index.js, so it still works, and CommonJS require("gaugekit") now loads that ES module through the interop path rather than index.cjs, which means a CommonJS consumer silently changes target. The gaugekit/format specifier stops resolving with ERR_MODULE_NOT_FOUND, because a subpath exists only if the map creates it, and gaugekit/lib/internal.js starts working, because without a map every file is reachable by path. That last one is why I keep a map on anything published: without it, every internal file becomes public API the day someone imports it.

Progress

Mark this lesson done and it stays marked in this browser.