A module is a file with its own scope
Nothing in a module is visible outside it unless you export it, and nothing from outside is visible unless you import it. That is the whole idea, and it is why modules replaced the era of scripts that all shared one global namespace.
- A module is deferred automatically — it runs after the HTML is parsed.
- It is always in strict mode.
- Top-level
thisisundefined, not the window. - It is fetched with CORS, so
file://will not work. Use a local server. - It is evaluated once, however many files import it.
<script type="module" src="/app.js"></script>
Named exports
export const esc = (s) => String(s).replace(/</g, "<");
export const slug = (s) => s.toLowerCase().replace(/\s+/g, "-");
// or collect them at the end
const pad = (n) => String(n).padStart(2, "0");
export { pad };
export { pad as padNumber }; // renamed on the way out
import { esc, slug } from "./utils.js";
import { pad as padNumber } from "./utils.js";
import * as utils from "./utils.js"; // everything as one object
Default exports
// Door.js
export default class Door { }
// importing — the name is yours to choose, which is the problem
import Door from "./Door.js";
import AnythingAtAll from "./Door.js"; // also valid
// mixed
import Door, { PRICE } from "./Door.js";
A named export has one spelling everywhere, so search, autocomplete and automatic renaming all work. A default export can be called something different in every file that uses it.
Paths
import { esc } from "./utils.js"; // relative — the extension is REQUIRED
import { esc } from "/ui/utils.js"; // from the site root
import { esc } from "https://…/utils.js"; // absolute
import { z } from "zod"; // a bare specifier — needs a bundler
// or an import map
<script type="importmap">
{ "imports": { "zod": "https://esm.sh/zod@3" } }
</script>
Dynamic import
This is how code splitting works: everything not needed for the first screen becomes a separate file that is fetched when it is actually used.
const { Chart } = await import("./chart.js");
// only load the heavy thing when it is needed
button.addEventListener("click", async () => {
const { openEditor } = await import("./editor.js");
openEditor();
});
// and the specifier can be computed, which a static import cannot
const mod = await import(`./locales/${lang}.js`);
Live bindings
You are looking at the exporting module's binding, not a snapshot of its value. You can read the new value, and you can never assign to it from outside.
// counter.js
export let count = 0;
export const inc = () => { count += 1; };
// app.js
import { count, inc } from "./counter.js";
console.log(count); // 0
inc();
console.log(count); // 1 — the binding updated
count = 5; // TypeError: imported bindings are read-only
Circular imports
A imports B and B imports A. Modules handle it without crashing, but one of them will see the other half-initialised — usually as undefined — at the moment it runs.
The fix is almost never clever ordering. It is to notice that the two files share something, and to move that shared thing into a third file both can import.
Side effects
Keep those rare and obvious. A module that quietly registers a global or patches a built-in when imported is the hardest kind of code to reason about, because nothing at the call site says it happened.
import "./analytics.js"; // no bindings; run it for its effects