Modules and Code Organization

Modules give every file its own scope and an explicit dependency graph. This lesson covers ESM as the browser runs it, plus the legacy patterns you will still meet.

Why modules exist

The problem: global soup and script-tag arithmetic

Before modules, a page loaded five <script> tags in an order you had to maintain by hand; every file shared one global namespace, so any two files could silently collide. A module fixes this structurally: each file gets its own scope, says explicitly what it needs (import) and what it offers (export). Lesson 02's type="module" is the on-switch: deferred, strict, and isolated.

Export and import

Named and default exports

// logger.js — one private variable, two named exports, one default.
let level = "info";

export function log(message) { console.log(`[${level}] ${message}`); }
export function setLevel(next) { level = next; }   // only THIS file touches `level`
export default function stamp() { return new Date().toISOString(); }
// main.js — three import forms.
import stamp, { log, setLevel } from "./logger.js"; // default first, then named
import * as logger from "./logger.js";              // or the whole module as an object

setLevel("debug");     // named: the EXACT exported names, in { }
log(stamp());          // default: any name we choose (one per module)
console.log(logger.level); // undefined — `level` was never exported: private

If you get this instead: SyntaxError: The requested module does not provide an export named 'log' — a spelling mismatch between export and import. And the path rule from lesson 02 applies twice over: imports need the ./ prefix and the file extension ("./logger.js", never just "logger").

Dynamic import()

Load it when it is needed

chartButton.addEventListener("click", async () => {
  // returns a promise — the module loads on first use, not on page load
  const { renderChart } = await import("./chart.js");
  renderChart(data);
});

Static imports load before your code runs; dynamic import() defers a heavy feature (a chart library, an editor) until the user actually opens it. Keep the common path light.

Circular dependencies

When A imports B and B imports A

Cycles are legal but fragile: during startup one module sees the other's not-yet-assigned bindings. The cure is architectural — extract what both need into a third module:

// BEFORE: cart.js imports prices.js AND prices.js imports cart.js  ← the cycle
// AFTER:
// shared.js   — the constant both need (e.g. TAX_RATE)
// prices.js   — imports shared.js
// cart.js     — imports shared.js + prices.js   ← a clean one-way chain

Rule of thumb: dependencies must form a tree, never a circle. If two modules keep reaching for each other, the boundary between them is wrong.

The legacy module pattern

IIFE namespaces — what you will still see

// Pre-2015 code faked modules with a function that runs immediately:
const Cart = (() => {          // IIFE: the function runs once, returns the API
  let items = [];              // private — closed over
  return {
    add(item) { items.push(item); },
    get count() { return items.length; },
  };
})();
Cart.add("tea");
console.log(Cart.count); // 1 — and `items` is unreachable from outside

Same encapsulation as a module, hand-rolled. You will read this pattern in older codebases; write export/import instead.

Practice: split a script

The task

Serve foundations/20_modules_lab/ over a local server (lesson 02) and open main.html — read all three files and trace what runs, in what order. Then split the click-counter demo (14) into counter.js (the logic) and main.js (the DOM wiring), with the state staying private.

The checklist

  • You can list the three guarantees type="module" adds (deferred, strict, isolated).
  • You know the import path rule: ./ prefix and full extension.
  • You can explain when to use dynamic import().
  • You can spot a circular dependency and describe the fix (extract a shared module).