064: JavaScript Modules: ESM, CommonJS, and Dynamic Imports
Learning outcomes
By the end of this lesson, you can:
- split data and functions into native ECMAScript modules;
- create and consume named and default exports;
- explain module scope, module specifiers, and live imported bindings;
- load a browser entry module with
<script type="module">from a local server; - diagnose common path, export-name, MIME, and origin errors.
Retrieval warm-up
Before introducing modules, retrieve a few ideas from the earlier work on small update functions:
- Why are small pure update functions easier to test?
- What does a function need to do to provide its output?
- Which syntax creates a new array around existing elements?
Expected ideas: deterministic input/output, return, and array spread.
Vocabulary
- Module: A file that exposes explicit imports and exports and has module-level scope. — Source: MDN: JavaScript modules
- Export: A declaration that makes a binding available to other modules. — Source: MDN: export
- Import: A statement that requests exported bindings from another module. — Source: MDN: import
- Named export: An export associated with an identifier that an importer normally requests by the same name. — Source: MDN: export
- Default export: The single unnamed export from a module, which an importer may bind to any local name. — Source: MDN: export
- Module specifier: The path or bare token that identifies the module to load. — Source: MDN: import
- Entry module: The first module loaded by HTML or the runtime (course term).
- Module graph: The entry module together with every module it imports, directly or transitively (course term).
- Live binding: An imported view that reflects later changes to the exporting binding. — Source: MDN: JavaScript modules
- Module (official): "A module is a file that imports and exports bindings via import/export statements." — Source: MDN: JavaScript modules
- Live binding (official): "Imported bindings are live views of the exported values — updating the export updates the import." — Source: ECMAScript: Modules
Beginner mental model
Think of a module as a unit with a front desk. Most names remain private inside the unit. Only exported names are available at the desk, and an importing module has to request those names using the correct syntax. The runtime does not paste import text into another file; it resolves, links, and evaluates a graph of modules.
That graph is the key shift from a collection of scripts to a modular program. Each edge records a dependency that the runtime and your tooling can inspect.
The standard browser and JavaScript syntax is ESM:
// pricing.js
export const TAX_RATE = 0.18;
export function getLineTotal(price, quantity) {
return price * quantity;
}
// main.js
import { TAX_RATE, getLineTotal } from "./pricing.js";
Do not fall back to older browser patterns such as globals, immediately invoked "module" wrappers, AMD, or CommonJS require() for this lesson. Native import and export are the language-standard model being established here.
Named imports use braces and normally have to match the names that were exported. You can change a local name with as, which we will see shortly. A module may expose many named exports, but it can have no more than one default export:
export default function formatCurrency(amount) {
return `$${amount.toFixed(2)}`;
}
import formatCurrency from "./format-currency.js";
The importer chooses the local name for a default import. That flexibility is convenient, but it can make a large codebase harder to search because the same export may have different local names. Prefer named exports when a file provides several utilities. Use a default export for a clearly primary feature, or follow the convention already established by the project.
The braces are therefore meaningful rather than decorative: they select named bindings from the module namespace. A default import has no braces because it selects the module's default binding. When an import fails, compare that syntax with the export form before looking for a more complicated explanation.
Worked beginner example: split a cart app
Here is a conceptual file layout. The lesson shows the files so that you can see the boundaries; you do not need to create additional files beside this content document:
index.html
main.js
products.js
cart.js
format-currency.js
To run the example, put these files in one directory, add <script type="module" src="./main.js"></script> to index.html, and serve the directory over HTTP. For example, run python -m http.server 8000 from that directory. Then open http://localhost:8000/. Do not open the HTML file with file://; browser module loading has origin and security requirements that a direct file open does not satisfy reliably.
products.js:
export const products = [
{ id: "p1", name: "Notebook", price: 4, stock: 12 },
{ id: "p3", name: "Water Bottle", price: 16, stock: 7 },
{ id: "p4", name: "Backpack", price: 45, stock: 3 },
];
export function findProduct(productId) {
return products.find((product) => product.id === productId);
}
cart.js:
export function addToCart(cart, productId, quantity = 1) {
const existing = cart.find((item) => item.productId === productId);
if (existing) {
return cart.map((item) =>
item.productId === productId
? { ...item, quantity: item.quantity + quantity }
: item,
);
}
return [...cart, { productId, quantity }];
}
export function getSubtotal(cart, products) {
return cart.reduce((total, item) => {
const product = products.find(
(item) => item.id === item.productId,
);
return total + (product?.price ?? 0) * item.quantity;
}, 0);
}
format-currency.js:
export default function formatCurrency(amount) {
return `$${amount.toFixed(2)}`;
}
main.js:
import { products, findProduct } from "./products.js";
import { addToCart, getSubtotal } from "./cart.js";
import formatCurrency from "./format-currency.js";
let cart = [];
cart = addToCart(cart, "p1", 2);
cart = addToCart(cart, "p3");
console.log(findProduct("p3")?.name);
console.log(cart);
console.log(formatCurrency(getSubtotal(cart, products)));
index.html:
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Module Cart</title>
</head>
<body>
<script type="module" src="./main.js"></script>
</body>
</html>
Console output:
Water Bottle
[
{ productId: "p1", quantity: 2 },
{ productId: "p3", quantity: 1 }
]
$24.00
Several browser details are doing work here. Relative module specifiers need ./ or ../, and they normally include the file extension. A module script is deferred automatically, so the HTML parser can finish before the module executes. Modules run in strict mode, and top-level declarations remain in module scope rather than becoming ordinary global names. Serve the directory over HTTP; opening index.html as file:// commonly fails because of module origin and security rules.
Named, default, and renamed imports
Use as when the name exported by a module would conflict with a local name or would be unclear in its new context:
import { getSubtotal as calculateSubtotal } from "./cart.js";
Imports are local read-only bindings. An importer cannot assign products = []. There is a subtle but important distinction here: if the exporter reassigns an exported let, importers see the new value because imports are live bindings. At the same time, an imported object or array can still have mutable properties or elements; importing an array does not freeze it. In practice, prefer exported functions that control updates rather than asking every consumer to mutate exported data directly.
Static imports appear only at the top level of a module. Dynamic import() is different: it is an expression that returns a promise. It is useful, but it is not needed for this one-hour foundation. Keep the static declaration form and the dynamic loading form distinct.
Intermediate example: design boundaries
A catalog module can keep its boundary clearer by operating on data supplied by its caller instead of owning a shared mutable collection:
// catalog.js
export function findProduct(products, productId) {
return products.find((product) => product.id === productId);
}
export function updateStock(products, productId, stock) {
return products.map((product) =>
product.id === productId ? { ...product, stock } : product,
);
}
This module has one job: operations on a product collection. It does not format currency, print output, or own UI state. main.js coordinates the modules, while the lower-level modules return values. That separation makes each function easier to test and makes the direction of data flow visible.
Avoid circular dependencies such as cart.js importing products.js while products.js imports cart.js. ESM specifies cycle behavior, but partially initialized live bindings can be surprising, especially when you are still building the mental model. Extract shared logic into a third module, or pass data as arguments as getSubtotal(cart, products) does.
Interview focus: Map versus Object
Both Object and Map associate keys with values, but they communicate different contracts. Use an Object for a record with a known schema and ordinary string or symbol fields. Use a Map for a dynamic key/value collection, especially when keys may be objects or when you need size, has, get, set, and predictable insertion-order iteration.
const counts = Object.create(null);
counts.apple = 2;
console.log(Object.hasOwn(counts, "apple")); // true
const visits = new Map();
const page = { id: "home" };
visits.set(page, 3);
console.log(visits.get(page)); // 3
console.log(visits.size); // 1
An object converts ordinary non-symbol keys to strings, so object[1] and object["1"] refer to the same property. A Map preserves key identity: two object literals that look equal are still different keys. That does not make Map universally better. JSON APIs and fixed records naturally use objects, and property access is often clearer for named fields.
Two useful interview follow-ups expose common mistakes. Why can obj.hasOwnProperty(key) be unsafe? Either the key or the object's prototype can shadow that method, so use Object.hasOwn. Why does map.get(key) sometimes return undefined? The key may be absent, or it may exist with an undefined value. Use map.has(key) as well when those cases need to be distinguished.
Optional advanced extension
One module can re-export selected features to define a public entry point for a small package:
// shop.js
export { addToCart, getSubtotal } from "./cart.js";
export { findProduct } from "./products.js";
export { default as formatCurrency } from "./format-currency.js";
import {
addToCart,
getSubtotal,
formatCurrency,
} from "./shop.js";
This kind of "barrel" module can provide a convenient public boundary. It can also hide the actual dependency paths and contribute to circular dependencies if used indiscriminately. Create one when it represents a meaningful API, not simply because every directory needs an index file.
Deep Dive: ESM, CommonJS, Dynamic Imports, and Module Boundaries
ESM
// money.js
export function formatMoney(amount) {
return new Intl.NumberFormat("en-IN", {
style: "currency",
currency: "INR",
}).format(amount);
}
// app.js
import { formatMoney } from "./money.js";
ES modules use static imports. Because those dependencies are declared in the module syntax, tools and runtimes can analyze the dependency graph before execution.
Static describes where the dependency is declared, not a promise that every module's work is synchronous. The runtime still has to fetch and evaluate modules, and evaluation follows the dependency graph. The benefit is that the graph is explicit before the application reaches the code that uses it.
CommonJS
CommonJS has historically been central to Node.js.
// money.cjs
function formatMoney(amount) {
return `₹${amount}`;
}
module.exports = { formatMoney };
const { formatMoney } = require("./money.cjs");
Do not mix ESM and CommonJS casually. First establish which module mode the runtime and project configuration expect, then use the interop rules for that environment deliberately.
Dynamic import
Use import() when a dependency is conditional or when loading it later is useful, such as for optional functionality:
async function loadAdminTools(isAdmin) {
if (!isAdmin) return null;
const module = await import("./admin-tools.js");
return module;
}
Dynamic import returns a Promise. The resolved value is the module namespace object, so the caller can access the exports from the loaded module.
Top-level await
In environments that support it, a module may use await at the top level:
const config = await fetch("/config.json").then((response) => response.json());
Use this carefully. A dependent module may have to wait for this module's evaluation to finish, so top-level asynchronous work can delay the rest of the graph.
Module design rule
A well-designed module exposes a small public surface and keeps implementation details private. Importing it should not unexpectedly mutate unrelated global state. That rule is less about the number of lines in a file than about making the module's effects and responsibilities easy to reason about.
Browser Module Metadata with import.meta
ES modules can expose host-provided metadata through import.meta.
A common browser property is import.meta.url:
console.log(import.meta.url);
It contains the URL of the current module.
That gives you a reliable base for constructing URLs to resources next to the module:
const iconUrl = new URL(
"./icons/cart.svg",
import.meta.url
);
console.log(iconUrl.href);
This avoids assuming that the document URL and the module URL are identical. That assumption breaks when a module is loaded from a different path or when the document is served through a deployment layout that differs from the source layout.
Tooling may provide additional import.meta properties, but those properties are environment-specific. Do not treat a bundler-specific property as part of JavaScript itself.
When a resource URL is wrong, log both the generated URL and the module's own URL. That gives you a concrete starting point for checking the request in the Network panel instead of reasoning from the document location alone.
Import Maps in Browsers
Native browser ESM normally expects URL-like specifiers:
import { formatMoney } from "./money.js";
An import map lets a browser map a bare specifier to a URL:
<script type="importmap">
{
"imports": {
"money": "/assets/money.js"
}
}
</script>
<script type="module" src="/assets/app.js"></script>
The application can then import the mapped name:
import { formatMoney } from "money";
Import maps belong to HTML and browser module resolution; they are not an npm feature. They are useful when you run native ESM directly in a browser or need to control module URLs without introducing a bundler.
Module Boundary Checklist
Before splitting a file, ask questions that test whether the proposed boundary will reduce complexity:
- Does this module own one clear concept?
- Can its exports be named without exposing internal state?
- Are side effects explicit?
- Is the import graph understandable?
- Is there a cycle?
- Does a dynamic import genuinely defer optional work?
- Would this boundary still make sense in a test?
A large collection of tiny files is not automatically modular design. A useful boundary reduces the amount of code and state you must hold in your head at once.
The right question is not "Can this code be moved to another file?" It is "Does moving it make ownership, dependencies, or testing clearer?" If the answer is no, another file may only add navigation cost.
Common mistakes and debugging
- Missing
type="module": The browser treats the file as a classic script and rejects static import syntax. - Opening with
file://: Start a local HTTP server and use its URL instead. - Wrong relative specifier: Browser imports generally need
./cart.js, not merelycart. - Wrong filename case: A case-sensitive server will reveal a mismatch that Windows may have hidden during local development.
- Named/default mismatch:
{ formatCurrency }asks for a named export; it does not import a default export. - Missing export: Every requested named export must exist under that exact exported name.
- Import inside a function: Static
importdeclarations are allowed only at module top level. - Trying to reassign an import: An imported binding is read-only in the importer.
- MIME/CORS failure: Read the Network and Console panels. Verify the server response, URL, JavaScript MIME type, and origin policy rather than guessing from the source code alone.
- Module logs only once: A module is evaluated once per resolved module in a graph, even when several other modules import it.
When debugging, start with the browser's exact module URL and response. A path problem, an export-name problem, and a server policy problem can all look like "the import did not work," but they require different fixes.
Best practices
- Use native ESM and explicit relative specifiers in browser lessons.
- Give each module one coherent responsibility.
- Prefer named exports for utility collections and consistent discoverability.
- Keep side effects in the entry module; let domain modules return values.
- Pass data across boundaries rather than hide mutable global state.
- Avoid circular imports and export only the intended public surface.
Checkpoint
Use four file labels: products.js, cart.js, format-currency.js, and main.js. Draw an arrow for every import, and check that dependencies point toward focused utilities rather than forming a circle. Identify which file owns reassignment, console output, product lookup, and formatting. Then intentionally mismatch one named import and one default import. Diagnose each problem from the syntax and the browser message. Finally, explain why adding more <script> tags and globals would make dependencies less explicit.
Exercises
Core
Write a named export isInStock(product) and its matching import from inventory.js.
// inventory.js
export function isInStock(product) {
return product.stock > 0;
}
// main.js
import { isInStock } from "./inventory.js";
console.log(isInStock({ name: "Notebook", stock: 2 }));
// true
Practice
Split applyDiscount(price, percent) into pricing.js, export it as a named export, and then import and call it from main.js.
// pricing.js
export function applyDiscount(price, percent) {
return price * (1 - percent / 100);
}
// main.js
import { applyDiscount } from "./pricing.js";
console.log(applyDiscount(80, 25));
// 60
The HTML entry remains <script type="module" src="./main.js"></script>. Verify that the browser console reports no module or CORS errors and that the expected output appears.
Professional Extension
Design cart.js with a named removeFromCart export and a default createCart export. Write the imports and output, using immutable-style operations.
// cart.js
export default function createCart() {
return [];
}
export function removeFromCart(cart, productId) {
return cart.filter((item) => item.productId !== productId);
}
// main.js
import createCart, { removeFromCart } from "./cart.js";
let cart = createCart();
cart = [
{ productId: "p1", quantity: 2 },
{ productId: "p3", quantity: 1 },
];
const nextCart = removeFromCart(cart, "p1");
console.log(nextCart);
console.log(cart.length);
Output:
[{ productId: "p3", quantity: 1 }]
2
Recap
Explain the syntax difference between named and default exports and imports. Explain module scope, and explain why imports are bindings rather than copied source text. What does the browser need in the HTML? Why should modules run through a server? Which layer should normally perform console.log: a domain utility or the entry module?
Official references
- MDN: JavaScript modules
- MDN:
export - MDN:
import - MDN:
<script type="module"> - ECMA-262: Scripts and Modules
- MDN:
Map - MDN:
Object.hasOwn()
Promise utility implementations
Promise.all is fail-fast, but it still preserves the order of its input. It must accept any iterable, adopt plain values, and attach handlers immediately. Attaching those handlers promptly matters because an early rejection should not become an unhandled rejection while the other inputs are being processed:
function promiseAll(iterable) {
return new Promise((resolve, reject) => {
const values = Array.from(iterable);
if (values.length === 0) { resolve([]); return; }
const results = [];
let remaining = values.length;
values.forEach((value, index) => {
Promise.resolve(value).then((result) => {
results[index] = result;
remaining -= 1;
if (remaining === 0) resolve(results);
}, reject);
});
});
}
promiseAll([Promise.resolve("a"), 2]).then((result) => {
console.assert(JSON.stringify(result) === JSON.stringify(["a", 2]));
});
Promise.race settles with whichever input settles first, whether that input fulfills or rejects. It also adopts plain values. An empty iterable produces a promise that never settles:
function promiseRace(iterable) {
return new Promise((resolve, reject) => {
for (const value of iterable) Promise.resolve(value).then(resolve, reject);
});
}
promiseRace([new Promise((resolve) => setTimeout(() => resolve("slow"), 10)), "fast"])
.then((value) => console.assert(value === "fast"));
Neither utility cancels the underlying work. If an operation supports cancellation, pair a timeout race with AbortController. Test empty input, plain values, an input order that differs from completion order, the first rejection, thenables, and an already-settled promise.
These implementations are deliberately small, but their edge cases are part of their contract. A successful test with two already-resolved values does not establish the ordering, rejection, iterable, or cancellation behavior by itself.
Promise interview questions
- Why must
promiseAllstore results by index rather than by completion order? - What does fail-fast mean, and why does it not cancel the other promises?
- What should
promiseRace([])do? - Why call
Promise.resolvefor each item? - How would you add
allSettledbehavior without rejecting on the first error?
