Viscosity
Review
Part I · JavaScript for engine builders · Day 3

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.

About 16 minutes, including the exercises and quiz
By the end you will be able to
  • 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.

Intuition

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.

Three modules. scanner.js keeps isDigit and pos private and exports scan. parser.js imports scan, keeps peek private and exports parse. main.js imports parse.scanner.jsisDigitposexport scanparser.jsimport scanpeekexport parsemain.jsimport parsesourcedashed: private to its filearrows: export → import
Export the menu, keep the kitchen. Three modules. Dashed names are private to their file. Each file exports a few names (the menu), and the next file imports them. main.js never sees scan or isDigit: it only needs parse.
Definition

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.

Try itA module in one snippet
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:

Step throughWhich module runs first?
Four modules and their imports. Each module runs after the modules it imports, each exactly once.main.jsparser.jsscanner.jserrors.jsRUN ORDER…
Each arrow means “imports”. main imports parser and errors; parser imports scanner and errors; scanner imports errors. Which file runs first?
1/9

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.

Definition

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.

Why compilers like modules

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.

ExerciseWhich module runs first?

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'));
Extra practiceA reader module

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

    scanner.js contains function isDigit(c) { ... } with no export. What happens if parser.js tries to import { isDigit } from './scanner.js'?

  2. 2.

    Which line imports the default export of parser.js?

  3. 3.

    a.js imports b.js and c.js (in that order). b.js imports c.js. c.js imports nothing. In what order do they run?

  4. 4.

    In the module pattern, what plays the role of export?

  5. 5.

    scanner.js imports parser.js to report errors, and parser.js imports scanner.js to get tokens. What is the best fix?

  6. 6.

    Why do exports matter to Viscosity’s safety checks?

References

  1. [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. [2] MDN contributors. Strict mode. MDN Web Docs. developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Strict_mode (checked 2026-10-31)