Modules

Article · 11:00

A module is a file with its own scope: nothing leaks out unless you export it, nothing comes in unless you import it, and the file runs exactly once however many times it is imported. That last part is what makes a module a good place to keep state.

This lesson has 14 runnable examples. Press Try yourself! under any of them to open it in the editor.

Modules

A module is a file with its own scope, loaded once, whose exports are resolved before any of it runs. Those three properties - private scope, single instance, static structure - are what make modules different from the script tags they replaced, and they explain every behaviour in this lesson.

The syntax, in one place

JS Example 1 13 lines
// --- maths.js ---
export const PI = 3.14159;                 // named, inline
export function area(r) { return PI * r * r; }

const internal = 'not exported';           // private to this file

const circumference = (r) => 2 * PI * r;
export { circumference };                  // named, at the bottom
export { circumference as perimeter };     // and renamed

export default class Circle {              // one default per module
  constructor(r) { this.r = r; }
}

Open this example in the editor and change it. Nothing you type is saved.

JS Example 2 15 lines
// --- app.js ---
import Circle from './maths.js';                   // the default
import { PI, area } from './maths.js';             // named
import { area as circleArea } from './maths.js';   // renamed
import * as maths from './maths.js';               // everything as a namespace

// Default and named together, in one statement. Shown separately
// because a name may only be bound once per file - importing Circle
// twice is a SyntaxError, not a duplicate that quietly wins.
//   import Circle, { PI } from './maths.js';

// Re-export, for a package's public entry point.
export { area, PI } from './maths.js';
export * from './shapes.js';
export { default as Circle } from './maths.js';

Open this example in the editor and change it. Nothing you type is saved.

The extension is required in the browser and in Node's ESM resolution. Bundlers often let you omit it; the moment you run the same file without one, the import fails. Write the extension.

Imports are hoisted and static

Every import is resolved and executed before any other code in the file, regardless of where you wrote it.

JS Example 3 2 lines
console.log(greeting);          // works - the import has already run
import { greeting } from './strings.js';

Open this example in the editor and change it. Nothing you type is saved.

That is not a style recommendation, it is the specification. And it has a consequence: you cannot import conditionally.

JS Example 4 2 lines
// if (dev) import './debug.js';        // SyntaxError
const module = await import('./debug.js');   // this is how

Open this example in the editor and change it. Nothing you type is saved.

import() is a function-like expression that returns a promise for the module namespace. It is how you load something conditionally, lazily, or by a name you compute:

JS Example 5 4 lines
if (user.wantsCharts) {
  const { renderChart } = await import('./charts.js');
  renderChart(data);
}

Open this example in the editor and change it. Nothing you type is saved.

The static structure is what makes tree-shaking possible. A bundler can see that area is imported and circumference is not, because the imports are literal strings resolved at build time rather than values computed at run time. Dynamic imports are a deliberate opt-out of that, which is why they create a separate chunk.

A module runs once

The first import evaluates the file. Every later import of the same resolved specifier gets the same namespace object, without re-running anything.

JS Example 6 4 lines
// --- counter.js ---
console.log('counter.js is evaluating');
export let count = 0;
export function increment() { return ++count; }

Open this example in the editor and change it. Nothing you type is saved.

JS Example 7 8 lines
// --- a.js ---
import { increment } from './counter.js';
increment();

// --- b.js ---
import { count, increment } from './counter.js';
increment();
console.log(count);        // 2 - a.js and b.js share one module

Open this example in the editor and change it. Nothing you type is saved.

"counter.js is evaluating" prints once. This is a module registry, not a copy per importer, and it is what makes a module the natural home for a connection pool, a configuration object or a cache.

It is also what makes module-level mutable state dangerous in tests: one test's mutation is visible to the next. Export a factory rather than an instance when that matters.

Bindings are live, and read-only to the importer

This is the part that surprises people who think of imports as copies.

JS Example 8 3 lines
// --- counter.js ---
export let count = 0;
export function increment() { count++; }

Open this example in the editor and change it. Nothing you type is saved.

JS Example 9 6 lines
// --- app.js ---
import { count, increment } from './counter.js';
console.log(count);        // 0
increment();
console.log(count);        // 1 - the binding is live, not a snapshot
// count = 5;              // TypeError: Assignment to constant variable

Open this example in the editor and change it. Nothing you type is saved.

An imported name is a live, read-only view of the exporting module's binding. The exporter can change it; the importer can only watch. That combination - shared truth, single writer - is a genuinely good design, and it is why export let plus an exported setter is a legitimate pattern rather than a smell.

Named or default

Prefer named exports. The reasons are practical rather than aesthetic:

  • A default export has no name at the import site, so two files can import the same thing under different names and nothing will tell you. Named imports have to match, and a typo is an error rather than undefined.
  • Tree-shaking and auto-import work better with named exports, because the tooling has a name to key on.
  • Refactoring is safer: renaming a named export breaks every importer loudly.

A default export is right when a module genuinely is one thing - a React component, a class that gives the file its name, a configuration object. It is wrong as a habit of exporting an object full of functions, which gives you the worst of both: one import, no tree-shaking, no rename safety.

JS Example 10 10 lines
const area = (r) => Math.PI * r * r;
const circumference = (r) => 2 * Math.PI * r;
const volume = (r) => (4 / 3) * Math.PI * r ** 3;

// Avoid: a bag of functions behind a default. One import, nothing
// shakeable, and no name to rename.
export default { area, circumference, volume };

// Prefer: three named exports, and let the importer take what it needs.
export { area, circumference, volume };

Open this example in the editor and change it. Nothing you type is saved.

Modules are strict, deferred and top-level-await capable

Four behaviours come free with type="module" or a .mjs file:

JS Example 11 14 lines
// 1. Always strict mode. No implicit globals, no sloppy this.
// 2. `this` at the top level is undefined, not globalThis.
console.log(this);                 // undefined

// 3. Deferred by default - a <script type="module"> does not block
//    parsing and runs after the document is parsed.

// 4. Top-level await, which a script cannot have:
//      const config = await fetch('/config.json').then((r) => r.json());
//      export { config };
//    Every module importing this one now waits for that fetch, which is
//    fine at an entry point and a hidden cost in a leaf utility.
const ready = await Promise.resolve('loaded before any importer runs');
console.log(ready);

Open this example in the editor and change it. Nothing you type is saved.

Top-level await is worth using carefully: every module that imports yours now waits for it. For a configuration load at the entry point that is fine. In a leaf utility it is a hidden delay on everyone.

CommonJS, and the interop that bites

Node still runs a great deal of require, and the two systems are not the same shape.

ESM CommonJS
Syntax import / export require / module.exports
Resolution Static, before execution Dynamic, at the call
Bindings Live, read-only A snapshot of the value
Conditional await import() only require() anywhere
Top-level await Yes No
__dirname No - use import.meta.dirname Yes
JS Example 12 4 lines
// The ESM replacements for the CommonJS globals:
import.meta.url;          // file:///path/to/this/file.js
import.meta.dirname;      // /path/to/this
import.meta.filename;     // /path/to/this/file.js

Open this example in the editor and change it. Nothing you type is saved.

ESM can import CommonJS; CommonJS cannot require ESM synchronously. A package declares which it is with "type": "module" in its package.json, and the error you get for mixing them - Cannot use import statement outside a module, or require() of ES Module - names the mismatch exactly.

A worked example

JS Example 13 43 lines
// Everything in one file, because the registry behaviour is the point
// and a single file can demonstrate it with dynamic import of a data URL.

// A module, as a string, so this example runs anywhere.
const source = `
  console.log('module body evaluated');
  export let count = 0;
  export function increment() { return ++count; }
  export const createCounter = () => { let n = 0; return () => ++n; };
  export default 'I am the default';
`;
const url = 'data:text/javascript,' + encodeURIComponent(source);

// First import: the body runs.
const first = await import(url);          // logs 'module body evaluated'
// Second import of the SAME specifier: nothing runs again.
const second = await import(url);         // logs nothing

console.log(first === second);            // true - one namespace object

// Live bindings, shared by both importers.
console.log(first.count);                 // 0
second.increment();
console.log(first.count);                 // 1 - first sees second's change

// The namespace object is sealed: no properties can be added or
// removed. It is not frozen, because a module may still reassign its
// own `export let` - the restriction is on the importer, not the author.
console.log(Object.isSealed(first));      // true
console.log(Object.isFrozen(first));      // false
try {
  first.count = 99;
} catch (error) {
  console.log(error.constructor.name);    // 'TypeError'
}

// The default is just a named export called 'default'.
console.log(first.default);               // 'I am the default'

// Module state is shared; a factory gives each caller its own.
const a = first.createCounter();
const b = first.createCounter();
console.log(a(), a(), b());               // 1 2 1

Open this example in the editor and change it. Nothing you type is saved.

The last three lines are the practical lesson. count is module state and is shared by everyone who imports the module - excellent for a cache, wrong for anything per-request. createCounter returns a closure, so each caller gets its own. When you are deciding where to put state, that is the question: should every importer see the same one?

When it goes wrong

Symptom Cause
Cannot use import statement outside a module Missing "type": "module" or a .mjs extension
require() of ES Module ... not supported CommonJS importing ESM; use await import()
Failed to resolve module specifier Missing file extension, or a bare specifier with no import map
An import is undefined Default imported as named, or a name typo
A module ran twice Two different specifiers resolved to two URLs
Assignment to constant variable on an import Imported bindings are read-only
A circular import gave undefined The other module had not finished evaluating
State leaked between tests Module-level mutable state; export a factory

A check you can run

Circular imports are the one failure mode you cannot reason your way around, so see it once:

JS Example 14 9 lines
// --- a.js ---
import { b } from './b.js';
export const a = 'A';
console.log('a.js sees b as', b);

// --- b.js ---
import { a } from './a.js';
export const b = 'B';
console.log('b.js sees a as', a);

Open this example in the editor and change it. Nothing you type is saved.

Import a.js and you get a ReferenceError: Cannot access 'a' before initialization from b.js, because b.js evaluated first and a was still in its temporal dead zone. Change both to export function and it works, because function declarations are fully hoisted.

That is the whole rule for cycles: functions survive them, values do not. The real fix is almost always to extract the shared thing into a third module that neither imports the other.