Modules
ES module import/export, default vs named exports, circular imports, and CJS interop.
ES modules give JavaScript first-class support for splitting code across files. Each file is its own module with a private scope - only what you explicitly export is visible to importers.
Named exports are the workhorse of ES modules. A file can export any number of named bindings; importers pick what they need. Names are fixed - renaming at the import site uses as.
// math.js
export function add(a, b) { return a + b; }
export function multiply(a, b) { return a * b; }
export const PI = 3.14159;
// main.js
import { add, PI } from "./math.js";
import { multiply as mul } from "./math.js";
console.log(add(2, 3)); // 5
console.log(PI); // 3.14159
console.log(mul(4, 5)); // 20
// Import everything under a namespace
import * as math from "./math.js";
console.log(math.add(1, 1)); // 2Default exports let a module export one primary value. Importers choose the local name freely - no braces required. A module can have both a default export and named exports.
// logger.js
export default function log(msg) {
console.log(`[LOG] ${msg}`);
}
export const LOG_LEVEL = "info";
// app.js
import log, { LOG_LEVEL } from "./logger.js";
// The importer chose "log" - could have been "writeLog", "print", anything
log("server started"); // [LOG] server started
console.log(LOG_LEVEL); // infoCircular imports - where A imports B and B imports A - are allowed by the spec but produce undefined for bindings that haven't been initialised yet at the point of import. The fix is to extract the shared code into a third module.
// a.js
import { b } from "./b.js";
export const a = "a";
console.log("in a, b is:", b); // "b" - b.js finished evaluating before this line
// b.js
import { a } from "./a.js";
export const b = "b";
console.log("in b, a is:", a); // undefined - a.js hasn't finished evaluating yet
// The order matters and is hard to predict at scale.
// Solution: move shared exports to shared.js and import from there.CJS/ESM interop: Node.js defaults to CommonJS (require). To use ES modules, either name the file .mjs or add "type": "module" to package.json. Dynamic import() works in both module systems and is the bridge when you need to load an ESM module from CJS code.
// package.json: { "type": "module" } - all .js files are ESM
// Static import - resolved at parse time, hoisted, cannot be conditional
import { add } from "./math.js";
// Dynamic import - returns a Promise, works anywhere, can be conditional
async function loadPlugin(name) {
if (name === "math") {
const { add } = await import("./math.js");
return add;
}
}
// In a CJS file (.cjs or without "type":"module"), use dynamic import
// to load ESM-only packages
async function main() {
const { default: chalk } = await import("chalk"); // chalk v5+ is ESM-only
console.log(chalk.green("hello"));
}In production
Default exports look convenient but they hurt refactor safety in shared code: the import name is chosen by the importer, not the author, so automated renames don't propagate and static analysis tools can't reliably track usage. A library that exports default function process() will be imported as doThing, handleIt, and runProcess across a large codebase - you lose the single source of truth that named exports provide. Reserve default exports for app-level files where one file represents one feature or page; use named exports everywhere else. Most team style guides (Airbnb, Google) enforce this and so do many ESLint rules.
Enjoyed this? Get more essays on software craft delivered to your inbox.
Subscribe free