#ES modules: named and default exports
ES modules (ESM) are JavaScript's built-in module system. In the browser you opt in with <script type="module">; in Node with .mjs files or "type": "module" in package.json.
A module is a file with its own top-level scope. Variables declared in it don't leak into the global scope, and nothing gets in or out except through explicit export and import. Before modules, every <script> shared one global scope: two libraries defining utils overwrote each other, and you had to order <script> tags by hand so dependencies loaded first. Modules fix both — each file declares exactly what it needs, and the engine works out the order.
The mental model: exports are named slots, and imports are wires connected to those slots before any code runs. An import doesn't copy a value; it gives your file a read-only view of a variable that lives in the other module. A default export is not a special mechanism either — it's just an export whose name happens to be default.
Module code is also always strict mode, top-level this is undefined, and in the browser module scripts are deferred automatically (they run after the HTML is parsed) and must be served over HTTP(S) — loading them from a file:// page fails with a CORS error in most browsers.
There are two kinds of export:
Named exports — many per file, imported with { } using the exact name
// math.js
export const PI = 3.14159;
export function add(a, b) {
return a + b;
}
const secret = 42; // not exported → private to the file// main.js
import { PI, add } from "./math.js";
import { add as sum } from "./math.js"; // renameDefault export — at most one per file, imported without { } under any name
// api.js
const api = "1234";
export default api;
// Other valid forms (still one per file):
// export default "1234";
// export default function getApi() {}
// ❌ export default let api = "1234";// main.js
import api from "./api.js";
import whatever from "./api.js"; // same value — you pick the name- Before any code runs, the engine parses
main.js, finds itsimportdeclarations, loads and parsesmath.jsandapi.js, and links every imported name to the matching export. If a name doesn't exist — sayimport { secret } from "./math.js"— linking fails withSyntaxError: The requested module './math.js' does not provide an export named 'secret', and not a single line of any of the modules runs. - Then evaluation starts with the dependencies.
math.jsruns its top level once, creatingPI,addandsecret.secretwas never exported, so it stays private to that file — no other module can reach it. import { PI, add }must use the exported names exactly.import { add as sum }wires a different local name to the same export, sosum === addistrue. Importingmath.jstwice doesn't run it twice — the second import reuses the already-loaded module.export default apiexports the value of the expressionapiunder the namedefault.import api fromis shorthand forimport { default as api } from, which is why the importer chooses the name:apiandwhateverboth hold"1234".export default let api = "1234"fails becauseexport defaultexpects an expression (or a function/class declaration), andlet api = …is a statement. Node reports it asSyntaxError: Unexpected strict mode reserved word, because in that position it readsletas an identifier.
// Both in one import
import React, { useState, useEffect } from "react";
// Namespace import: group every named export into one object
import * as utils from "./utils.js";
utils.add(1, 2);
utils.default; // the default export, if there is oneimport React, { useState, useEffect }takes the default export (namedReactlocally) and two named exports in one statement. The default always comes first, outside the braces.import * as utilscreates a module namespace object: one property per export, anullprototype, and live values (if the module changes an exported variable,utils.thatNameshows the new value).- The namespace is locked:
utils.foo = 1throwsTypeError: Cannot assign to property 'foo' of [object Module]. You can read exports through it, not add or replace them. utils.defaultis the default export, exposed under its real name. If the module has no default export, it'sundefined.- Bundlers can still tree-shake a namespace import as long as you only use static property access like
utils.add; passingutilsaround as a whole value forces them to keep everything.
Without the extension, Node fails with ERR_MODULE_NOT_FOUND. The browser has a related rule: bare specifiers like "react" don't resolve on their own — you need a bundler or an import map to tell the browser which URL "react" means.
One subtle difference between the two export styles: export default api exports a snapshot of the value, not a live binding to the variable api.
// dflt.js
let value = "first";
export default value; // the value at this moment
export { value as liveDefault }; // a live binding to the variable
export function change() {
value = "second";
}// main.js
import snap, { liveDefault, change } from "./dflt.js";
change();
console.log(snap, liveDefault); // first secondexport default valueevaluates the expressionvaluewhiledflt.jsruns and stores the result ("first") in a hidden binding calleddefault. From then on, it's disconnected from the variable.export { value as liveDefault }exports the variable itself under another name — importers get a live view of it.change()reassignsvalueto"second"insidedflt.js.snapstill reads the hidden default binding →"first".liveDefaultreads the variable →"second".- In practice this rarely bites, because defaults are usually functions or classes that never get reassigned. (
export default function f() {}is a live binding tof.) It's a good example of how precise the "imports are live" rule is: they're live views of bindings, and a default expression creates its own binding.