Skip to content
academia.sh

Lesson 02 / 14

ES Modules

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

Contents

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.

// 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);
}
// 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), '');
}
// 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 };
// 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.

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.

// file: record.mjs
console.log('record.mjs body ran');
export let callCount = 0;
export function count() { callCount += 1; }
// 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:

// 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:

// 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:

// 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:

// 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.

To keep your progress and take notes, Log in

My notes

Log in to take notes.

Start typing to search.

↑↓ Esc navigate · open · close