Module: JavaScript
JavaScript·064·9 MIN READ

064: JavaScript Modules: ESM, CommonJS, and Dynamic Imports

TOPICS COVERED: 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

  1. Why are small pure update functions easier to test?
  2. What does a function need to do to provide its output?
  3. Which syntax creates a new array around existing elements?

Expected ideas: deterministic input/output, return, and array spread.

Vocabulary

  • Module: File exposing explicit imports/exports with module-level scope. — Source: MDN: JavaScript modules
  • Export: Declaration making a binding available to importers. — Source: MDN: export
  • Import: Statement pulling exported bindings from another module. — Source: MDN: import
  • Named export: Export bound to an identifier imported by matching name. — Source: MDN: export
  • Default export: Single unnamed export imported with any chosen name. — Source: MDN: export
  • Module specifier: Path/bare token identifying the module to load. — Source: MDN: import
  • Entry module: the first module loaded by HTML or the runtime (course term).
  • Module graph: entry module plus all transitively imported modules (course term).
  • Live binding: Imported view 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

A module is a unit with a front desk. Most names stay inside. Only exports appear at the desk, and an importer must request them correctly. Imports are not text copied into a file; the runtime resolves, links, and evaluates a module graph.

Use standard ESM syntax:

js
// pricing.js
export const TAX_RATE = 0.18;
export function getLineTotal(price, quantity) {
  return price * quantity;
}
js
// main.js
import { TAX_RATE, getLineTotal } from "./pricing.js";

Do not use obsolete browser patterns such as globals, immediately invoked "module" wrappers, AMD, or CommonJS require() for this lesson. Native import/export is the language-standard model.

Named imports use braces and must match exported names (unless renamed with as). A module can have many named exports but at most one default export:

js
export default function formatCurrency(amount) {
  return `$${amount.toFixed(2)}`;
}
js
import formatCurrency from "./format-currency.js";

The importer chooses the local name of a default import, which can make large codebases harder to search. Prefer named exports when a file exposes several utilities; reserve default for a clear primary feature or follow the project's established convention.

Worked beginner example: split a cart app

Create this conceptual structure (the lesson presents files, but no extra files are required alongside this content document):

text
index.html
main.js
products.js
cart.js
format-currency.js

To run this module example, put the files in one folder, add <script type="module" src="./main.js"></script> to index.html, and serve that folder over HTTP (for example, python -m http.server 8000). Open http://localhost:8000/; do not open the page with file://.

products.js:

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:

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:

js
export default function formatCurrency(amount) {
  return `$${amount.toFixed(2)}`;
}

main.js:

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:

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:

text
Water Bottle
[
  { productId: "p1", quantity: 2 },
  { productId: "p3", quantity: 1 }
]
$24.00

In browsers, relative specifiers need ./ or ../ and normally include the extension. The module script is deferred automatically. Modules run in strict mode and top-level declarations stay in module scope. Serve the directory over HTTP; opening index.html as file:// commonly fails due to module origin/security rules.

Named, default, and renamed imports

Rename a named import to avoid a local conflict:

js
import { getSubtotal as calculateSubtotal } from "./cart.js";

Imports are local read-only bindings. You cannot assign products = [] in the importer. If the exporter reassigns an exported let, importers observe the new value because imports are live bindings. Object properties can still be mutable, so an imported array is not automatically frozen. Prefer exported functions to control updates instead of encouraging consumers to mutate exported data.

Static imports appear only at the top level of modules. Dynamic import() exists and returns a promise, but it is not needed for this one-hour foundation. Do not confuse it with static import declarations.

Intermediate example: design boundaries

A stronger catalog module can avoid sharing mutable storage by exporting a query over provided data:

js
// 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 responsibility: product collection operations. It does not format currency, print output, or own UI state. main.js coordinates modules, while each lower-level module returns values.

Avoid circular dependencies (cart.js imports products.js, which imports cart.js). ESM defines cycle behavior, but partially initialized live bindings can surprise beginners. Extract shared logic into a third module or pass data as arguments, as getSubtotal(cart, products) does.

Interview focus: Map versus Object

Both structures associate keys with values, but their contracts differ. 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 when keys may be objects or when you need size, has, get, set, and predictable insertion-order iteration.

js
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 coerces ordinary non-symbol keys to strings, so object[1] and object["1"] address the same property. A Map preserves key identity, so two equal-looking object literals are still different keys. Map is not automatically better: JSON APIs and fixed records naturally use objects, and object property access is often clearer for named fields.

Interview follow-ups: Why can obj.hasOwnProperty(key) be unsafe? The key or the prototype may shadow that method; use Object.hasOwn. Why does map.get(key) return undefined sometimes? The key may be absent, or it may be present with an undefined value, so pair it with map.has(key) when that distinction matters.

Optional advanced extension

Re-export selected features through one public module:

js
// shop.js
export { addToCart, getSubtotal } from "./cart.js";
export { findProduct } from "./products.js";
export { default as formatCurrency } from "./format-currency.js";
js
import {
  addToCart,
  getSubtotal,
  formatCurrency,
} from "./shop.js";

This "barrel" can create a convenient public boundary, but unnecessary barrels can obscure dependencies and contribute to cycles. Use one only when it defines a meaningful API.

Deep Dive: ESM, CommonJS, Dynamic Imports, and Module Boundaries

ESM

js
// money.js
export function formatMoney(amount) {
  return new Intl.NumberFormat("en-IN", {
    style: "currency",
    currency: "INR",
  }).format(amount);
}
js
// app.js
import { formatMoney } from "./money.js";

ES modules use static imports, which allows tools and runtimes to analyze dependencies before execution.

CommonJS

CommonJS is historically central to Node.js.

js
// money.cjs
function formatMoney(amount) {
  return `₹${amount}`;
}

module.exports = { formatMoney };
js
const { formatMoney } = require("./money.cjs");

Do not mix ESM and CommonJS casually; understand the module mode of your runtime/project.

Dynamic import

Use import() when a dependency is conditional or can be loaded later.

js
async function loadAdminTools(isAdmin) {
  if (!isAdmin) return null;

  const module = await import("./admin-tools.js");
  return module;
}

Dynamic import returns a Promise.

Top-level await

In environments that support it, modules can use await at top level:

js
const config = await fetch("/config.json").then((response) => response.json());

Use this carefully because module evaluation can delay dependents.

Module design rule

A good module exposes a small public surface and hides implementation details. Importing a module should not unexpectedly mutate unrelated global state.

Browser Module Metadata with import.meta

ES modules can expose host-provided metadata through import.meta.

A common browser use is import.meta.url:

js
console.log(import.meta.url);

It contains the current module's URL.

That makes relative resource construction reliable:

js
const iconUrl = new URL(
  "./icons/cart.svg",
  import.meta.url
);

console.log(iconUrl.href);

This avoids assuming the document URL and module URL are the same.

Tooling may expose additional import.meta properties, but those are environment-specific. Do not assume a bundler-specific property is part of JavaScript itself.

Import Maps in Browsers

Browser ESM normally expects URL-like specifiers:

js
import { formatMoney } from "./money.js";

An import map can map a bare specifier to a URL:

html
<script type="importmap">
{
  "imports": {
    "money": "/assets/money.js"
  }
}
</script>

<script type="module" src="/assets/app.js"></script>

Then:

js
import { formatMoney } from "money";

Import maps are an HTML/browser module-resolution feature, not npm itself. They are useful when running native ESM directly in browsers or controlling module URLs without a bundler.

Module Boundary Checklist

Before splitting a file, ask:

  • 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 number of tiny files is not automatically modular design. A module boundary should reduce reasoning cost.

Common mistakes and debugging

  • Missing type="module": browser rejects static import syntax in a classic script.
  • Opening with file://: start a local HTTP server and use its URL.
  • Wrong relative specifier: browser imports need ./cart.js, not usually cart.
  • Wrong filename case: case-sensitive servers expose mistakes that Windows may hide.
  • Named/default mismatch: { formatCurrency } does not import a default export.
  • Missing export: requested named export must exist under that exact name.
  • Import inside a function: static import declarations are top-level only.
  • Trying to reassign an import: imported bindings are read-only in the importer.
  • MIME/CORS failure: read the Network and Console panels; verify server status, URL, JavaScript MIME type, and origin policy.
  • Module logs only once: modules are evaluated once per resolved module in a graph, even if imported repeatedly.

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 each 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 from 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.

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 named, then import and call it from main.js.

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 the browser console has no module or CORS errors and that the expected output appears.

Professional Extension

Design cart.js with named removeFromCart and default createCart. Write the imports and output, using immutable-style operations.

js
// 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:

text
[{ productId: "p3", quantity: 1 }]
2

Recap

Explain named versus default syntax, module scope, and why imports are bindings rather than copied source text. What does the browser need in HTML? Why should modules run through a server? Which layer should normally perform console.log: domain utility or entry module?

Official references

Promise utility implementations

Promise.all is fail-fast but preserves input order. It must accept any iterable, adopt plain values, and attach handlers immediately so an early rejection does not become an unhandled rejection:

js
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 the first input to settle, whether fulfilled or rejected. It also adopts values and resolves an empty iterable that never settles:

js
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. Pair a timeout race with AbortController when the operation supports cancellation. Test empty input, plain values, order differing from completion order, first rejection, thenables, and an already-settled promise.

Promise interview questions

  1. Why must promiseAll store results by index rather than completion order?
  2. What does fail-fast mean, and why does it not cancel other promises?
  3. What should promiseRace([]) do?
  4. Why call Promise.resolve for each item?
  5. How would you add allSettled behavior without rejecting on the first error?