Modules

Split code into files with explicit inputs and outputs.

15of 32
≈ 10Min read
8Parts

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 this is undefined, 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.
loading one
<script type="module" src="/app.js"></script>

Named exports

utils.js
export const esc = (s) => String(s).replace(/</g, "&lt;");
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
and importing them
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

one per file, and it has no name of its own
// 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";
Prefer named exports

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

what the browser accepts
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
an import map, if you want bare names with no build step
<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.

a promise, so the code can be loaded on demand
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.

an import is a view, not a copy
// 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.

a module can just do something
import "./analytics.js";     // no bindings; run it for its effects

Practice