Lesson 21 of 28
Modules
Named and default exports, live bindings, dynamic import for code splitting, and circular imports — plus how ESM differs from the require calls still everywhere.
Before modules, every script shared one global namespace. Two libraries both
defining $, a variable named config overwritten by a file loaded later, and
a page that broke when you reordered your script tags. Modules exist so a file
can decide what it shares and nothing else escapes.
Named exports
Mark anything you want to share with export, and pick it up by name.
// lib/pricing.js
export const TAX_RATE = 0.18;
export function withTax(amount) {
return amount * (1 + TAX_RATE);
}
function formatPaise(amount) {
return (amount / 100).toFixed(2);
}
export { formatPaise };// checkout.js
import { withTax, TAX_RATE } from "./lib/pricing.js";The names must match what was exported. formatPaise shows the other form:
declare first, export at the bottom in one list. Both are fine, though inline
export keeps the two facts in one place.
Rename on either side with as, which you need when two modules export the
same name:
import { withTax as addGst } from "./lib/pricing.js";
import { format as formatDate } from "./lib/date.js";Or bring the whole module in as one object:
import * as pricing from "./lib/pricing.js";
pricing.withTax(1900);Namespace imports read well for a module that is genuinely a toolkit. For everything else, importing three names is clearer than importing a bag.
Default exports
A module can nominate one main thing.
// lib/logger.js
export default function createLogger(prefix) {
return (message) => console.log(prefix, message);
}import createLogger from "./lib/logger.js"; // no braces
import makeLogger from "./lib/logger.js"; // also legal, and the problemThe importer chooses the name, so the same function ends up called three different things across a codebase and none of them are searchable. Prefer named exports. Use a default only where a framework asks for one — a Next.js page component, for example.
You can mix both, and re-export to build a public surface for a folder:
// lib/index.js
export { withTax, TAX_RATE } from "./pricing.js";
export { default as createLogger } from "./logger.js";Module scope is not global
Everything declared in a module file is private to that file unless exported.
Two modules can both declare const config and never collide. There is no
implicit sharing, and top-level this is undefined rather than the global
object.
Modules are also strict mode by default, and each module runs exactly once no
matter how many files import it. That last point makes a module the simplest
possible singleton: a const cache = new Map() at the top of a module is
shared by every importer.
It also means top-level code in a module is a side effect that runs on first import. An import with no bindings exists for exactly that:
import "./styles.css";
import "./polyfills.js";Keep those rare. A module that does work merely by being imported is hard to test and hard to reason about when the import order changes.
Imports are hoisted and static
Import statements are processed before any of your code runs. The engine reads the whole dependency graph first, then executes. Two consequences.
The specifier must be a plain string literal. You cannot compute it, and you
cannot put an import inside an if:
import { chart } from "./charts/" + name; // SyntaxErrorAnd an import is hoisted to the top of the file regardless of where you wrote it, so this works even though it looks wrong:
setup();
import { setup } from "./setup.js";That is legal, not advisable. Keep imports at the top where readers expect them.
Live bindings
An import is a window onto the exporter's variable, not a copy of its value.
// counter.js
export let count = 0;
export function increment() {
count += 1;
}// main.js
import { count, increment } from "./counter.js";
console.log(count); // 0
increment();
console.log(count); // 1 — the binding is liveYou cannot assign to count from main.js; imported bindings are read-only
there. Exporting mutable state like this is rarely a good idea, but knowing the
rule explains a lot of otherwise baffling behaviour.
Try it yourself
The playground has no module system, so this uses an IIFE as a stand-in for
module scope, and a promise-returning function in place of await import().
Dynamic import
When you do need a computed path or a module loaded on demand, import() is a
function that returns a promise for the namespace object.
async function showChart(points) {
const { renderChart } = await import("./lib/chart.js");
renderChart(points);
}The real payoff is code splitting. A charting library that only runs on one
tab does not belong in the bundle every visitor downloads. Bundlers see the
import() call and emit a separate chunk fetched at that moment. Load it on
the click, on the route change, or when the element scrolls into view.
Remember it resolves to the namespace, so a default export arrives as
.default:
const { default: createEditor } = await import("./lib/editor.js");Circular imports
Two modules that import each other will load, but one of them starts running
before the other has finished. Function declarations are hoisted so they
usually survive; a const read at the top level will be in its temporal dead
zone and throw, or arrive as undefined.
Do not learn to work around this. A cycle is a design signal. Move the shared piece into a third module both can import, or move the function that reaches back across the cycle into the module that needs it.
ESM and CommonJS
You will meet both. CommonJS is Node's older system:
const { withTax } = require("./pricing");
module.exports = { withTax };require is synchronous and runs at the moment that line executes, so it can
take a computed path and sit inside an if. ESM is static and asynchronous,
which is what makes tree shaking and top-level await possible.
In Node, a .mjs file is ESM and a .cjs file is CommonJS. A plain .js file
follows the nearest package.json: "type": "module" makes it ESM, and its
absence means CommonJS. ESM in Node also wants the file extension in relative
specifiers. Most of the confusing errors in this area — Cannot use import statement outside a module, or require is not defined — are that setting
disagreeing with your code.
What to remember
- A module shares only what it exports; everything else stays private to the file.
- Prefer named exports, keep one concern per file, and reserve default exports for frameworks that require them.
- Static imports are hoisted and their paths must be literal strings; imports are live bindings.
- Use
import()for on-demand loading and code splitting, and treat a circular import as a design problem.
Check yourself
4 questions · pass 3/4 to unlock Promises in Depth
1.A module exports
let count = 0and anincrement()function. Another module imports both, callsincrement(), then logscount. What appears?2.What happens if the module specifier is a variable, as in
import { chart } from path?3.Why do most teams prefer named exports over a default export?
4.What does
await import("./chart.js")give you?
4 left to answer