---
title: 'ES Modules'
source: 'https://academia.sh/en/courses/javascript-ecosystem/es-modules'
course: 'Modules, Tooling and the Ecosystem'
language: en
updated: '2026-08-17T18:09:45+00:00'
license: 'CC BY-SA 4.0'
---

# ES Modules

Named and default export, import's live-binding behavior, binding coming before evaluation, and the opportunity static structure gives to tree shaking.

The previous lesson left the single-file measurement script behind and raised the real
question: when a file gets split, who decides which name stays visible from outside? In
the language's standard module syntax, the module itself makes that decision, and
declares it in writing, inside the source.

This lesson takes up that syntax and the mechanism behind it. The syntax is short; the
mechanism is the source of several behaviors that will be used throughout the course.

## The Export Surface

The measurement script splits into three responsibilities. Splitting the text goes to
one module, numeric measurement to a second, and the entry point that combines the two
to a third.

```javascript
// file: src/splitting.mjs
const WORD_SEPARATOR = /[^\p{L}\p{N}]+/u;
const SENTENCE_SEPARATOR = /[.?]+/;

export function splitWords(text) {
  return text.split(WORD_SEPARATOR).filter((s) => s.length > 0);
}

export function splitSentences(text) {
  return text.split(SENTENCE_SEPARATOR).map((c) => c.trim()).filter((c) => c.length > 0);
}
```

```javascript
// file: src/statistics.mjs
export function averageLength(words) {
  if (words.length === 0) return 0;
  const total = words.reduce((t, s) => t + s.length, 0);
  return total / words.length;
}

export function longestWord(words) {
  return words.reduce((longest, s) => (s.length > longest.length ? s : longest), '');
}
```

```javascript
// file: src/index.mjs
import { splitSentences, splitWords } from './splitting.mjs';
import { longestWord, averageLength } from './statistics.mjs';

export function measure(text) {
  const words = splitWords(text);
  return {
    wordCount: words.length,
    sentenceCount: splitSentences(text).length,
    averageLength: averageLength(words),
    longest: longestWord(words),
  };
}

export { splitSentences, splitWords };
```

```javascript
// file: try.mjs
import { measure } from './src/index.mjs';

console.log(measure('A module carries its own scope. A script runs in the global scope.'));
```

```
$ node try.mjs
{
  wordCount: 13,
  sentenceCount: 2,
  averageLength: 4,
  longest: 'carries'
}
```

Two details matter. First, the `WORD_SEPARATOR` constant is now visible only in its
own module; since it never receives `export`, it cannot be reached from outside.
Visibility is a property of the **declaration**, not of the file boundary.

Second, `index.mjs`'s last line **re-exports** two names. The entry point carries part
of the sub-modules' surface onto its own, gathering the interface the package offers
outward in one place. `averageLength` and `longestWord` were not added to this list:
they stay internal computation detail. The export surface is a design decision, and it
sets how much the library is free to change later.

## Named and Default Export

There are two forms. A **named export** places a name on the surface; the consumer
takes it by writing that name. A **default export** offers a single, nameless value;
the consumer gives it whatever name it wants.

```javascript
export function measure(text) { /* ... */ }         // named
export default function measure(text) { /* ... */ }  // default
```

The distinction is not just a matter of writing convenience. In a named export, the
name is part of the module's contract: a misspelled name is caught at the binding
stage. In a default export, the name is the consumer's choice; two files can call the
same value by different names, and automated tools cannot track that name. In library
interfaces, the named form builds a stronger contract for cases where the name has to
stay stable.

## An Imported Name Is a Binding

Importing does not copy a value; it establishes a **live binding** to the declaration
in the exporting module. When the exporting module changes the value, the importing
side sees the new one.

```javascript
// file: record.mjs
console.log('record.mjs body ran');
export let callCount = 0;
export function count() { callCount += 1; }
```

```javascript
// file: main.mjs
console.log('first line of main.mjs body');
import { callCount, count } from './record.mjs';

console.log('imported value:', callCount);
count();
count();
console.log('after two calls:', callCount);
```

```
$ node main.mjs
record.mjs body ran
first line of main.mjs body
imported value: 0
after two calls: 2
```

`callCount` is a number — an immutable primitive — and yet the second read gave `2`.
What is being read is not the value, it is the **binding**.

The binding is **one-directional**. The importing side cannot write to it:

```javascript
// file: assignment.mjs — record.mjs is the file above
import { callCount } from './record.mjs';

callCount = 5;
```

```
$ node assignment.mjs 2>&1 | grep -E 'Error' | head -1
TypeError: Assignment to constant variable.
```

This restriction guarantees that only a module's own code can change its state. If an
outside change is genuinely needed, the module has to export a function for it.

## Binding Comes Before Evaluation

The order of the output lines stands out: `record.mjs`'s body ran **before**
`main.mjs`'s own console line — even though the import statement came after that line
in the source.

The reason is that module loading runs in three separate phases:

1. **Parsing and resolution.** The source is read, import specifiers are converted
   into files, and the process repeats for every file found. The result is a module
   graph.
2. **Binding.** Every imported name is matched against a declaration in the exporting
   module. A name with no match throws here.
3. **Evaluation.** Module bodies run once each, dependency by dependency.

This was the linker's job in the How Computers Work course's linking-and-loading
lesson: resolving symbols and reporting missing ones. The module system's second phase
does the same job at the source level. The result is similar too — a missing name is
caught before any code runs:

```javascript
// file: broken.mjs — record.mjs exports only callCount and count
import { callCount, counter } from './record.mjs';

console.log('this line never runs', callCount, counter);
```

```
$ node broken.mjs 2>&1 | grep 'SyntaxError'
SyntaxError: The requested module './record.mjs' does not provide an export named 'counter'
```

The error type stands out: a **syntax error**. The module graph's consistency is a
property checked before the program starts. The same holds for a missing file:

```javascript
// file: missing-dependency.mjs
console.log('this line does not run');
import './nonexistent-module.mjs';
```

```
$ node missing-dependency.mjs 2>&1 | grep '^Error' | sed "s|$(pwd -P)/||g"
Error [ERR_MODULE_NOT_FOUND]: Cannot find module 'nonexistent-module.mjs' imported from missing-dependency.mjs
```

Nothing was ever printed to the console. The `sed` call is only there to shorten the
absolute directory path in the message; the path varies depending on the directory you
run it from.

## The Namespace Object

All of a module's exports can also be gathered into a single object:

```javascript
// file: namespace.mjs — src/index.mjs is the entry point defined above
import * as meter from './src/index.mjs';

console.log(Object.keys(meter));
console.log(Object.isFrozen(meter));
console.log(meter[Symbol.toStringTag]);
```

```
$ node namespace.mjs
[ 'measure', 'splitSentences', 'splitWords' ]
false
Module
```

The keys' order is not the source's writing order, it is **alphabetical order**: the
namespace object's content is produced deterministically, independent of the source
text's layout. The object is not frozen, but it is not extensible either; no new name
can be added to it, and no existing name can be deleted.

## The Consequence of Static Structure

Import and export statements can only appear at the top level, and their specifiers
have to be string literals. At first glance, this restriction looks like rigidity that
reduces flexibility; in exchange, it becomes possible to resolve the code **without
running it**.

The concrete gain is that unused exports can be dropped from the output; this is
called **tree shaking**. A tool can look at the source and see that `index.mjs` uses
only the name `measure`, and leave the `splitSentences` function out of the output
entirely. Making the same inference in a system where names are computed at runtime is,
in general, impossible.

The second gain is that tools can build the module graph without running the program.
Every bundler, analyzer, and editor feature you will see throughout this course rests
on this property.

## Summary

- Export is decided at the declaration level; a name with no `export` cannot be
  reached from outside the file, and the entry point gathers the interface in one
  place through re-export.
- In a named export, the name is part of the contract and a misspelling is caught at
  the binding stage; in a default export, the consumer chooses the name.
- An imported name is not a copy of a value; it is a live binding to the declaration
  in the exporting module, and it cannot be written to from the importing side.
- Loading has three phases: resolution, binding, evaluation. Missing-name and
  missing-file errors surface in the first two phases, before any body runs.
- The syntax being static is what makes tree shaking possible, along with building the
  graph without running the code.

## Next Step

It would be misleading to think this model is the only option. The language has a
second, older module system, still commonly encountered, where importing is not a
statement but a **function call**, and every behavior branches off from that. The next
lesson examines that system, how it lives alongside this one, and how the two diverge
on circular dependencies.
