Modules and Code Organization
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).