ES Modules

Mental model: A module is a file with a private scope and a published list of names. An import is not a copy of a value, it is a window onto the variable that lives in the other file.

Level: intermediate · about 18 minutes

// math.js
const TAU = Math.PI * 2;          // private to this file
export const PI = Math.PI;
export function circleArea(r) {
  return PI * r * r;
}

// app.js
import { PI, circleArea } from './math.js';

console.log(PI);                  // -> 3.141592653589793
console.log(circleArea(2));       // -> 12.566370614359172
console.log(TAU);                 // ReferenceError: TAU is not defined

Two files. Only what you export can be seen from outside.

A module is a single file with its own scope. Nothing inside it is visible to any other file unless you export it, and nothing from another file is visible inside it unless you import it. That is the whole idea. Everything else in this lesson is a consequence of it.

You may have heard that modules are "just a way to split files up". They are stricter than that. The list of names a module exports is worked out before a single line of it runs, which is what makes tools able to see your dependency graph without executing your code.

Named exports

// 1. inline, on the declaration
export const VERSION = '2.1.0';
export function parse(text) { /* ... */ }
export class Parser {}

// 2. one list at the bottom, which reads like a table of contents
const VERSION = '2.1.0';
function parse(text) { /* ... */ }
export { VERSION, parse };

// 3. the same list, renaming on the way out
export { parse as parseConfig, VERSION as CONFIG_VERSION };

Three spellings of the same thing. Pick one per file and stay consistent.

import { parse } from './config.js';
import { parse as parseCsv } from './csv.js';   // two parses, no collision

import * as config from './config.js';          // namespace object
console.log(config.VERSION);                     // -> '2.1.0'
console.log(typeof config);                      // -> 'object'

import './analytics.js';                         // run it, import nothing

Renaming on the way in, and grabbing the whole namespace.

Default exports

// Button.js
export default function Button(props) { /* ... */ }
export const SIZES = ['sm', 'md', 'lg'];   // defaults and named exports coexist

// app.js
import Button from './Button.js';           // no braces, name is yours to choose
import Btn from './Button.js';              // legal, and now the codebase has two names
import Button, { SIZES } from './Button.js';

A default export is a named export whose name happens to be default.

Import formWhat you getNotes
import { a } from mthe named export athe name must match exactly
import { a as b } from mthe same binding, called b locallyuse it to avoid collisions
import d from mthe default exportthe local name is arbitrary
import * as ns from ma namespace object of every exportsealed, read only, ns.default for the default
import mnothingevaluates the module for its side effects only
export * from mrepublishes every named export of mthe default is not included
export { x } from mrepublishes one name without importing it locallythe classic barrel file line
// components/index.js
export * from './Button.js';
export * from './Modal.js';
export { default as Table } from './Table.js';

// elsewhere
import { Button, Table } from './components/index.js';

A barrel file. Convenient, and it has a cost worth knowing about.

Module scope is real privacy

// This is what a module does, expressed with a closure so it runs here.
const counterModule = (() => {
  let count = 0;                     // module scope: private
  const bump = () => ++count;        // exported
  const read = () => count;          // exported
  return { bump, read };             // the export list
})();

counterModule.bump();
counterModule.bump();
console.log(counterModule.read());   // -> 2
console.log(typeof count);           // -> 'undefined', the name does not exist out here

The pattern modules replaced. Run it: secret cannot be reached from outside.

Before modules, that IIFE was how you got privacy, and everything public went onto one global object. Modules make the private part the default. A top level const in a module file is not on globalThis, does not collide with a const of the same name in another file, and cannot be read by a browser extension poking at the page.

A module evaluates exactly once

function makeLoader(definitions) {
  const cache = new Map();
  const evaluated = [];

  function require(id) {
    if (cache.has(id)) return cache.get(id);        // second and later calls
    const factory = definitions[id];
    if (!factory) throw new Error("Cannot find module '" + id + "'");
    evaluated.push(id);
    const exports = factory(require);
    cache.set(id, exports);
    return exports;
  }

  return { require, evaluated };
}

const loader = makeLoader({
  'config.js': () => ({ url: '/api' }),
  'client.js': (req) => ({ base: req('config.js').url }),
  'log.js': (req) => ({ target: req('config.js').url }),
});

loader.require('client.js');
loader.require('log.js');
console.log(loader.evaluated);                     // -> [ 'client.js', 'config.js', 'log.js' ]
console.log(loader.require('config.js') === loader.require('config.js')); // -> true

A hand written loader with a cache. This is the behaviour the real module system gives you for free.

Note that config.js ran once even though two modules asked for it, and both got the same object back. Real ESM works the same way: the module registry is keyed by resolved URL, so ten importers share one evaluation and one set of bindings. That is why a module can hold state (a cache, a connection, a store) and why importing it twice does not reset that state.

// counter.js
export let count = 0;
export const bump = () => ++count;

// a.js
import { bump } from './counter.js';
bump();

// b.js
import { count } from './counter.js';
import './a.js';
console.log(count);

One evaluation of counter.js is shared, so a.js and b.js see the same count variable. bump() incremented it, and because an import is a live binding rather than a copy, b.js reads the new value: 1.

Live bindings, not copies

A copied value freezes in time

// snapshot semantics
const { count } = counterModule;
counterModule.bump();
console.log(count);   // still the old
                      // number: you copied
                      // it at destructure
                      // time

A live binding follows the variable

// import semantics
import { count } from './counter.js';
bump();
console.log(count);   // the new number:
                      // the import points
                      // at the variable in
                      // the other module

An import is closer to a getter than to an assignment. The importing module holds a reference to the exporting module's binding, so when that variable is reassigned, every importer sees the change on its next read.

function makeCounter() {
  let count = 0;
  return {
    copied: count,                 // a copy, taken now
    get live() { return count; },  // a window onto the variable
    bump() { count += 1; },
  };
}

const c = makeCounter();
c.bump();
c.bump();

console.log(c.copied);             // -> 0, frozen at export time
console.log(c.live);               // -> 2, reads the variable now

const { live } = c;                // destructuring reads it once
c.bump();
console.log(live, c.live);         // -> 2 3

The difference, shown inline: a copied number versus a closure over the variable.

How the graph is loaded

  1. Construct Starting from the entry file, fetch and parse each module, read its import and export statements, and repeat for every specifier found. This builds the whole graph before any of your code runs, which is why specifiers must be statically analysable strings.
  2. Instantiate Allocate the bindings and wire every import to the exact variable it points at. Nothing has a value yet, so the bindings sit in the temporal dead zone. This step is what makes live bindings and hoisted function exports possible.
  3. Evaluate Run each module body, depth first, deepest dependency first, once each. If a module appears twice in the graph it is still evaluated once. Exceptions during evaluation reject the whole load.
  entry: app.js
     |
     +-- ./ui/view.js -----+
     |                     |
     +-- ./data/store.js --+--> ./util/format.js
     |                     |
     +-- ./data/api.js ----+

  format.js is imported three times.
  It is fetched once, evaluated once, and all three
  importers share the same bindings.

  evaluation order (depth first):
  format.js -> view.js -> store.js -> api.js -> app.js

Circular imports

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

// b.js
import { a } from './a.js';
export const b = 'B';
console.log('b.js sees', a);      // ReferenceError: Cannot access 'a' before initialization

// entry: import './a.js'
// a.js starts, needs b.js, so b.js evaluates first.
// b.js reads 'a' from a.js, but a.js has not reached that line yet.

A cycle that throws. Read the evaluation order out loud and you can see why.

A cycle is not automatically fatal. The graph loads fine, and function declarations survive because they are hoisted into the binding before evaluation. What breaks is reading a const or let from a module that has not finished running, which is the temporal dead zone showing up across file boundaries.

function findCycle(graph) {
  const state = new Map();               // id -> 'visiting' | 'done'
  const path = [];

  function walk(id) {
    if (state.get(id) === 'visiting') return path.slice(path.indexOf(id)).concat(id);
    if (state.get(id) === 'done') return null;
    state.set(id, 'visiting');
    path.push(id);
    for (const dep of graph[id] ?? []) {
      const cycle = walk(dep);
      if (cycle) return cycle;
    }
    path.pop();
    state.set(id, 'done');
    return null;
  }

  for (const id of Object.keys(graph)) {
    const cycle = walk(id);
    if (cycle) return cycle;
  }
  return null;
}

console.log(findCycle({ 'a.js': ['b.js'], 'b.js': ['c.js'], 'c.js': [] }));      // -> null
console.log(findCycle({ 'a.js': ['b.js'], 'b.js': ['a.js'] }));                  // -> [ 'a.js', 'b.js', 'a.js' ]

Detecting a cycle in a dependency graph, which is exactly what your bundler prints a warning about.

Modules in the browser

<script type="module" src="/assets/js/main.js"></script>

<!-- a fallback for browsers that predate modules, ignored by modern ones -->
<script nomodule src="/assets/js/legacy-bundle.js"></script>

One script tag, one entry point. The browser finds the rest.

strict mode
always on, you cannot opt out, so no accidental globals and this is undefined at the top level
deferred
fetched in parallel with parsing, executed after the document is parsed, in document order
own scope
top level declarations are module scoped, not added to window
CORS
fetched with CORS rules, so file:// pages fail and cross origin needs the right headers
extensions
the specifier is a URL, so ./util.js needs the .js, unlike Node CommonJS resolution
once per URL
the same URL is only fetched and evaluated once, even across script tags
top level await
allowed in a module, which delays the modules that import it

Import maps

<script type="importmap">
{
  "imports": {
    "lodash-es": "https://cdn.example.com/lodash-es@4.17.21/lodash.js",
    "@app/": "/assets/js/"
  }
}
</script>
<script type="module">
  import { debounce } from 'lodash-es';      // resolved by the map
  import { store } from '@app/core/store.js'; // prefix mapping, note the trailing slash
</script>

An import map lets a browser resolve bare specifiers, with no build step.

Without a map, a browser has no idea what lodash-es means: there is no node_modules to search, so a bare specifier is simply an error. The map is the browser equivalent of Node resolution, written by you. It must appear before the first module script, and there can be only one per document.

Try it yourself

A module system in twenty lines

function makeLoader(definitions) {
  const cache = new Map();
  function require(id) {
    if (cache.has(id)) return cache.get(id);
    const factory = definitions[id];
    if (!factory) throw new Error("Cannot find module '" + id + "'");
    const exports = factory(require);
    cache.set(id, exports);
    return exports;
  }
  return require;
}

let evaluations = 0;
const require = makeLoader({
  'config.js': () => { evaluations += 1; return { retries: 3 }; },
  'api.js': (req) => ({ retries: req('config.js').retries }),
  'ui.js': (req) => ({ retries: req('config.js').retries }),
});

console.log(require('api.js'), require('ui.js'));
console.log('config.js evaluated', evaluations, 'time(s)');

Add a module that requires itself and watch the loader recurse. Then add a loading set so it reports a cycle instead of overflowing the stack.

Copy versus window

function createModule() {
  let mode = 'light';
  return {
    snapshot: mode,                      // copied once
    get current() { return mode; },      // read on access
    setMode(next) { mode = next; },
  };
}

const theme = createModule();
console.log('before:', theme.snapshot, theme.current);
theme.setMode('dark');
console.log('after: ', theme.snapshot, theme.current);
// -> before: light light
// -> after:  light dark

Change snapshot to a getter and watch the output flip. This is the difference between a value and a binding, with no import statement in sight.

Exercises

Build a module loader with a cache

Write createLoader(definitions). definitions maps module ids to factory functions, and each factory is called with a require function and returns that module's exports. Return { require, evaluated } where require(id) returns the exports, runs each factory at most once, and throws an Error whose message is Cannot find module 'x' for an unknown id. evaluated is an array of ids in the order their factories first ran.

Classify an import specifier

Write classifySpecifier(spec) returning 'relative' for anything starting ./ or ../, 'absolute' for anything starting with a single /, 'url' when it starts with a scheme such as https://, file:// or data:, and 'bare' for everything else (package names like lodash or @scope/pkg). Throw a TypeError for a non string or an empty string.

Check yourself

A module logger.js is imported by four other files. How many times does its top level code run?
Once — The module registry is keyed by resolved URL. The first import evaluates the file, and every later import gets the cached bindings. That is why a module can safely hold a cache or a connection.
What does this print?
'ada' — Imports are live bindings, not copies. login reassigns the user variable inside state.js, and the importing module reads that same variable, so it sees ada. Assigning to user in app.js would be the TypeError.
Which statement about type="module" is false?
Top level declarations become properties of window — Module top level scope is module scope. var x = 1 in a module does not create window.x, which is one of the defaults classic scripts could never change.
Two modules import each other and one reads a const from the other at the top level. What happens?
A ReferenceError, because the binding is still in its temporal dead zone — The graph loads and the bindings exist, but one module runs before the other has reached its declaration, so reading it hits the TDZ. Function declarations survive a cycle because they are initialised during instantiation. The real fix is to extract the shared piece into a third module.

Common mistakes

  • Thinking an import copies a value. It points at the exporting module's variable.
  • Assigning to an imported binding, which is a TypeError rather than a helpful error message.
  • Leaving mutable module level state in place between tests, so test order starts to matter.
  • Omitting the file extension in a browser specifier, because Node CommonJS let you.
  • Renaming a default export at every import site until the codebase has three names for one file.
  • Reaching for a barrel file everywhere, then spending an afternoon on the circular import it created.

Takeaways

  • A module is a private scope plus a published list of names, and privacy is the default.
  • Exports are named or default. Named exports keep one spelling, which every tool depends on.
  • A module evaluates once per resolved URL, so its top level state is shared by every importer.
  • Imports are live, read only bindings onto the exporting module's variables.
  • The graph is built before evaluation, which is why specifiers must be static strings.
  • A circular import is a design signal: extract the shared piece into a third module.
  • type="module" means strict, deferred, module scoped and fetched with CORS.