12. Modules: import and export
An interpreter is too big for one file. Modules let each file keep its own secrets and share only what it chooses, so a big program becomes a set of small ones with clear jobs.
- Explain why a module's names are private unless it exports them.
- Read and write export and import statements between files.
- Work out the order in which a set of modules runs.
- Split a program into modules with one clear job each, and imitate a module in a single snippet.
One file is too many names
By the end of Part III your interpreter will have a scanner, a parser,
an evaluator and a set of error classes. Put all of that in one file and
you get thousands of lines in which every name can see every other name.
The scanner has a variable pos for its place in the text. The parser
wants a pos for its place in the list of tokens. One of them gets
renamed pos2, and a month later somebody uses the wrong one.
The deeper problem is that you can’t tell who uses what. Can you change how the scanner works inside? Only if you read the whole file to check that nothing else reaches into it.
A module is a restaurant. The kitchen is full of things: knives, pans, half-made sauces. Customers never see them. What they get is the menu: a short list of dishes the kitchen promises to serve. The cooks can reorganize the kitchen however they like, as long as the menu still works.
Private by default
In JavaScript, a module is simply a file. Every name declared at
the top of a module is private to that file unless you mark it with
export:
// scanner.js
function isDigit(c) {
return c >= '0' && c <= '9';
}
export function scan(src) {
// ... uses isDigit ...
}
Another file asks for an exported name with import, naming the file it
comes from:
// parser.js
import { scan } from './scanner.js';
export function parse(src) {
const tokens = scan(src);
// ...
}
parser.js can call scan. It can’t see isDigit at all, and
writing isDigit there is just an unknown name. So the scanner’s author
is free to rewrite isDigit, rename it or delete it, and nothing outside
can break.
A module is a file with its own scope: its top-level names are
private unless they are exported. export in front of a declaration
shares it. import { a, b } from './file.js' brings exported names into
another module[1]. A file can also have one
default export (export default parse;), imported without braces:
import parse from './parser.js';.
Two details are worth knowing. An imported name is read-only in the
importing file: like a const, you can’t assign a new value to it,
although if it refers to an object you can still change that object.
And module code automatically runs in JavaScript’s strict mode[1],
which turns some silent mistakes into thrown errors[2].
A module in one snippet
The code runner in this book runs one snippet at a time, so import
can’t run here: there are no other files to import from. Real projects
use real files with export and import, and so will this book from
Part III on. But you already know a way to get the same effect inside
one snippet: a function’s local variables are private, and a closure can
keep them alive. Return an object holding just the functions you want to
share:
function makeCounter() {
let count = 0; // private
function next() {
count++;
return count;
}
return { next }; // the "exports"
}
const c = makeCounter();
c.next(); // 1
c.next(); // 2
console.log(c.count); // undefined
{ next } is shorthand for { next: next }. Nothing outside can read
or reset count; the only way in is through next. A function that
hides its variables and returns an object of functions is often called
the module pattern. It needs no special syntax, just the closures
you met in chapter 6.
function makeCounter() {
let count = 0;
function next() {
count++;
return count;
}
function reset() {
count = 0;
}
return { next, reset };
}
const a = makeCounter();
const b = makeCounter();
a.next();
a.next();
console.log(a.next(), b.next());
console.log(a.count);Who runs first?
When one module imports another, the imported one has to run first, so that the names it exports exist. A program’s imports form a graph, and JavaScript walks it depth-first: before running a module, it makes sure everything that module imports has run[1]. Each module runs once, however many files import it. Step through it:
That is recursion again, from chapter 9. “Get this module ready” means “get each of its imports ready, then run it”. A module with no imports is the base case.
What if two modules import each other? That’s a cycle, and it can
fail: one of them has to run first, while the other’s names don’t exist
yet. Code that reads them too early gets a ReferenceError. The usual
fix is to move the shared code into a third module that both
import[1].
Giving each module one job
Splitting a program is a design decision. Good splits follow two rules of thumb. Each module has one clear job that you can say in a sentence. And the imports point one way, from the bigger picture down to the details, so there are no cycles. Your interpreter will be split like this:
main.js read a file, run it
interpreter.js compute values from a tree
parser.js tokens → tree
scanner.js text → tokens
errors.js the error classes
Each file imports only from files below it in the list. If you change how
the scanner finds numbers, only scanner.js changes, and the error
classes, which everything uses, depend on nothing at all.
Program structure is how a program is divided into modules. Aim for modules with one job each, small exports that hide the details, and imports that point one way, from high-level modules down to low-level ones.
An import declaration can only appear at the top level of a module,
and it names its file with a plain string[1]. So
a tool can find a program’s whole web of files by reading the source,
without running any of it. (There is also a dynamic import() form that
works differently, which this book won’t need.) Bundlers rely on this to
find code that nothing imports and leave it out[1]. Exports also matter to Viscosity. Remember aliasing
from chapter 4: once an array of particles is exported, any importing
file might hold an arrow to it and change it. An exported name is a
door out of the module, and Viscosity’s escape analysis will have to
check every door before it changes how data is laid out.
Your turn
The core exercise works out the run order from the step-through above. The extra one builds a scanner-style reader with the module pattern.
A program's imports are given as an object: each module name maps to the array of names it imports, in the order its import lines appear.
const deps = {
main: ['parser', 'errors'],
parser: ['scanner', 'errors'],
scanner: ['errors'],
errors: [],
};Write loadOrder(deps, entry) that returns the array of module names in the order they run, starting from entry: every module comes after all the modules it imports (taken in order), and each appears once. For the deps above, loadOrder(deps, 'main') is ['errors', 'scanner', 'parser', 'main']. Modules that entry never reaches don't run. If there is a cycle, don't loop forever: a module that is already being loaded is skipped.
function loadOrder(deps, entry) {
// your code here
}
const deps = {
main: ['util'],
util: [],
};
console.log(loadOrder(deps, 'main'));Use the module pattern to write makeReader(src). It returns an object with three functions: peek() returns the current character without moving on; next() returns the current character and moves one step on; done() returns true once every character has been read. The position must be private: reader.pos must be undefined. Each reader keeps its own position. (This is the start of a real scanner.)
function makeReader(src) {
// your code here
}
const r = makeReader('ab');
console.log(r.next(), r.peek());What you learned
- A module is a file whose top-level names are private unless it exports them. Other files import what they need.
- Imported names are read-only, and module code runs in strict mode.
- Modules run after everything they import, depth-first, and each runs once. Cycles are risky: move shared code into a third module.
- The module pattern (a function returning an object of functions, with closures over private variables) gives the same privacy in one snippet.
- Good program structure: one job per module, small exports, and imports that point one way.
That’s the end of Day 3. Tomorrow: TypeScript, which lets the computer check your work before it runs, then testing and raw bytes.
Check your understanding
0/6- 1.
scanner.jscontainsfunction isDigit(c) { ... }with noexport. What happens ifparser.jstries toimport { isDigit } from './scanner.js'? - 2.
Which line imports the default export of
parser.js? - 3.
a.jsimportsb.jsandc.js(in that order).b.jsimportsc.js.c.jsimports nothing. In what order do they run? - 4.
In the module pattern, what plays the role of
export? - 5.
scanner.jsimportsparser.jsto report errors, andparser.jsimportsscanner.jsto get tokens. What is the best fix? - 6.
Why do exports matter to Viscosity’s safety checks?
References
- [1] MDN contributors. JavaScript modules (JavaScript Guide). MDN Web Docs. developer.mozilla.org/en-US/docs/Web/JavaScript/Guide/Modules (checked 2026-10-31)
- [2] MDN contributors. Strict mode. MDN Web Docs. developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Strict_mode (checked 2026-10-31)