Foundations Updated 2026-09 View as Markdown

Modules

How JavaScript code is split across files — and the CommonJS/ESM split that still causes more confusion than anything else in the ecosystem.

A module is a file whose top-level declarations are private unless exported.

src/pricing.js
export const TAX_RATE = 0.2;

export function total(items) {
  return items.reduce((sum, i) => sum + i.priceCents, 0);
}

function internalHelper() {}     // not exported — invisible outside this file
src/cart.js
import { total, TAX_RATE } from "./pricing.js";

console.log(total(items) * (1 + TAX_RATE));

Import forms#

javascript
import { total } from "./pricing.js";              // named
import { total as cartTotal } from "./pricing.js"; // renamed
import * as pricing from "./pricing.js";           // everything as a namespace
import formatter from "./format.js";               // the default export
import formatter, { currency } from "./format.js"; // both
import "./side-effects.js";                        // run it, import nothing

Named over default#

javascript
export default function parse() {}     // works, and prefer not to
export function parse() {}             // better

Named exports are worth defaulting to. The name is fixed at the source, so every import uses the same word, rename refactors work across the codebase, and editor auto-import is reliable. A default export can be imported under any name, which is how one function ends up called three things.

Dynamic import#

import() is a function that returns a promise, so it can run conditionally and at runtime:

javascript
if (user.wantsCharts) {
  const { renderChart } = await import("./charts.js");
  renderChart(data);
}

The chart library is only downloaded and parsed if it is needed. This is the mechanism behind code splitting, and on the server it is how you avoid paying startup cost for a path most requests never take.

ESM and CommonJS#

The long-running source of confusion. Node originally had its own module system, CommonJS, and later gained the standard one, ESM.

javascript
// CommonJS — the old system
const { total } = require("./pricing");
module.exports = { total };

// ESM — the standard
import { total } from "./pricing.js";
export { total };

Which one a .js file uses is decided by the nearest package.json:

package.json
{ "type": "module" }        // .js files are ESM
{ "type": "commonjs" }      // .js files are CommonJS (the default if absent)

You can also be explicit per file: .mjs is always ESM, .cjs is always CommonJS.

Use ESM for anything new. It is the standard, it works in browsers, it supports top-level await, and the ecosystem has largely moved.

The differences that bite#

CommonJSESM
Loadingsynchronous, at runtimeasynchronous, statically analysed
__dirnameavailableuse import.meta.dirname
Top-level awaitnoyes
Importing the othercan require CJS onlycan import CJS, with caveats
Conditional importrequire() anywhereawait import()
javascript
// ESM equivalents of the CommonJS globals
import.meta.dirname       // __dirname
import.meta.filename      // __filename
import.meta.url           // the module's URL

A CommonJS file cannot require() an ESM module. That is the source of "this package is ESM-only" — the fix is to move your project to ESM, or use await import() from an async context.

Circular imports#

javascript
// a.js
import { b } from "./b.js";
export const a = () => b();

// b.js
import { a } from "./a.js";
export const b = () => a();

ESM handles cycles better than CommonJS, but they still produce partially-initialised modules and confusing undefined errors. A cycle almost always means two modules share a concept that wants extracting into a third.

Import maps and bare specifiers#

javascript
import { total } from "./pricing.js";     // relative — a file path
import express from "express";            // bare — resolved from node_modules

In the browser, bare specifiers need an import map:

html
<script type="importmap">
  { "imports": { "lodash-es": "https://cdn.example.com/lodash-es/lodash.js" } }
</script>

Exercise#

javascript
// Split this into modules:
//   math.js       — exports add and multiply (named)
//   format.js     — exports a default currency formatter
//   main.js       — imports both, and dynamically imports a "stats.js"
//                   module only when an array has more than 10 items
console.log("start");

Common questions#

Why does my import fail without a file extension?#

ESM requires the full specifier, including .js. CommonJS guessed at extensions and index files and ESM deliberately does not, because the resolution has to be statically analysable. Bundlers hide this, which is why it appears only when Node runs the file directly.

Should I use default or named exports?#

Named. Fixed names mean consistent imports, working rename refactors and reliable auto-import. Reserve the default export for a module with one obvious primary thing, and even then a named export costs nothing.

How do I use an ESM-only package from CommonJS?#

You cannot require() it. Either convert your project to ESM by adding "type": "module", or use await import() from inside an async function. The first is the real fix; the second is a workaround for a file you cannot convert yet.

Get the JavaScript agent pack

A battle-tested AGENTS.md, the review checklist, and the failure-mode cheat sheet for JavaScript. One email, then occasional updates when the tooling shifts. No course pitch.

Unsubscribe in one click. We never sell the list. Or just take the AGENTS.md now — no email needed.