Lesson 01 / 14
The Need for Modules
The name-collision problem born from sharing the global scope, the namespace pattern built with closures, and module scope's answer to that problem.
Contents
The Asynchronous JavaScript and the Runtime course covered how a single program runs and how its problems are diagnosed. This course moves up one level: to the question of how the pieces find one another, and who can see what, once a program no longer fits in one file.
The question looks innocent. Splitting code into two files is assumed to be nothing more than loading the files one after another. Seeing where this assumption breaks also explains why the concept of a module was added to the language.
The Global Scope Is a Shared Resource
The language’s first unit of execution was the script: source text is parsed, and its top-level declarations are written to the global scope. Two separate files, loaded one after another in the same runtime, do not each build a separate world; both write to the same global object.
This behavior can be observed directly through the runtime’s script-evaluation interface:
// file: scripts.mjs import vm from 'node:vm'; // Two separate "scripts", run in the same global scope. vm.runInThisContext('var version = "chart-1.0"; function format(d) { return "chart: " + d; }'); vm.runInThisContext('var version = "report-2.0"; function format(d) { return "report: " + d; }'); console.log(globalThis.version); console.log(globalThis.format('x'));
$ node scripts.mjs report-2.0 report: x
The first script’s version variable and format function vanished. No error, no
warning: the second declaration wrote over the first. The program keeps running and
produces the wrong result.
The problem’s size grows not with the number of files but with the number of names. If twenty files each define five global names, a shared pool of a hundred names forms, and keeping that pool collision-free depends on nothing but the authors’ care. The scope concept established in the Programming Fundamentals course turns here into an operational problem: a shared, writable namespace is an unisolated resource.
The second problem is order. If one file uses a function another defines, load order matters — but that order is not written in the code itself; whoever loads it has to know it separately. Dependency information sits outside the source, not inside it.
The Namespace Built with Closures
Before the language had module syntax, the answer to these two problems was to use closures’ scope-isolation property. A function is defined and called immediately, its inner names stay inside that function’s scope, and only a chosen object is handed outward:
// file: iife.mjs var Measurer = (function () { var separator = /\s+/; function words(text) { return text.trim().split(separator); } return { words: words }; })(); console.log(Measurer.words('one two three')); console.log(typeof separator);
$ node iife.mjs [ 'one', 'two', 'three' ] undefined
separator is not visible from outside; only the name Measurer is written to the
global scope. This pattern is a direct application of the immediately invoked function
pattern from the Objects and Functions in JavaScript course, and it delivers a real
gain: one global name instead of a hundred.
It also loses as much as it gains. The namespace object itself is still in the global
scope, and it can still collide. Dependencies are still carried by convention: if
Measurer expects some other namespace to already be loaded, it has no written way to
declare that. Beyond that, finding where a given name comes from means scanning every
file, not reading the source.
Module Scope
A module solves these problems at the language’s parsing level. When a source text is parsed as a module, its top-level declarations are written not to the global scope but to a scope specific to that module:
// file: scope.mjs const version = 'text-measurer-1.0'; function format(d) { return 'measurer: ' + d; } console.log(typeof globalThis.version); // the module-scope name is not on the global object console.log(typeof globalThis.format); console.log(this); // this at the top level of a module
$ node scope.mjs undefined undefined undefined
All three lines of output say the same thing: a module’s top level is not the
global scope. The names version and format are not visible from outside; the
top-level this is bound not to the global object but to undefined.
The difference from the namespace pattern is where the isolation comes from. In the closure pattern, isolation was a structure the programmer built, and it could break; in a module, isolation is the parser’s decision, and it cannot break.
The Script/Module Distinction
The same source text can be parsed under two different rule sets. The distinction is not limited to scope alone:
| Criterion | Script | Module |
|---|---|---|
| Top-level declarations | Written to the global scope | Stay in module scope |
Top-level this |
The global object | undefined |
| Strict mode | Must be requested explicitly | Always on |
| Import/export syntax | Invalid | Valid |
| Evaluation timing | The moment it is encountered | After dependencies are resolved |
The last row is decisive and will be detailed in the next lesson. Which rule a text
gets parsed under is decided by a marker the runtime reads: the file extension, the
type field in the manifest, or type information given by whoever loads the script.
The same character sequence takes on a different meaning depending on that marker.
What Is Expected of a Module System
The problems a module system has to solve can be drawn straight from the two experiments above:
Isolation. A module’s internal names should not be visible from outside; what it hands outward should be the module’s own choice.
Explicit interface. What a module depends on and what it offers should be written in the source itself. Dependency information should live in the code, not in the loader’s memory.
Deterministic resolution. A dependency name should map to the same file in every environment. The name-resolution rule should be written down and predictable.
Single evaluation. When the same module is requested from more than one place, it should be evaluated once; the state it holds should be the same for every consumer. This guarantee is the language-level counterpart of the symbol resolution introduced in the linking-and-loading lesson of the How Computers Work course: a name binds to exactly one definition.
These four guarantees are the criterion for every mechanism examined throughout this course. Package resolution is an implementation of the third; caching is an implementation of the fourth.
The Course’s Example
One concrete example will be built throughout the course: a small library package
called text-measurer. Its job is computing a given text’s word and sentence count,
average word length, and longest word. It starts as a single-file script; it gets split
into modules, its export surface gets defined, it gains a dependency, its version gets
tagged, it becomes part of a workspace, and it finally gets wired into build and
analysis tools.
Its first form is a single file, and it exports nothing:
// file: meter.mjs const WORD_SEPARATOR = /[^\p{L}\p{N}]+/u; const SENTENCE_SEPARATOR = /[.?]+/; function splitWords(text) { return text.split(WORD_SEPARATOR).filter((s) => s.length > 0); } function splitSentences(text) { return text.split(SENTENCE_SEPARATOR).map((c) => c.trim()).filter((c) => c.length > 0); } function averageLength(words) { if (words.length === 0) return 0; return words.reduce((t, s) => t + s.length, 0) / words.length; } function measure(text) { const words = splitWords(text); return { wordCount: words.length, sentenceCount: splitSentences(text).length, averageLength: averageLength(words), }; } console.log(measure('A module carries its own scope. A script runs in the global scope.'));
$ node meter.mjs
{ wordCount: 13, sentenceCount: 2, averageLength: 4 }
Three separate responsibilities are interleaved in this file: splitting the text,
computing numeric measures, and presenting the result. Since all three share the same
scope, they see each other’s names directly. As the file grows, this visibility stops
being a design decision and turns into a source of accidents: the WORD_SEPARATOR
constant is exposed to the entire file even though only the splitting functions care
about it.
The next lesson splits this file along its responsibilities and examines the effect of that split on the export surface. Splitting’s real question is not which code goes into which file, but which name stays visible from outside.
Summary
- A source parsed as a script writes its top-level declarations to the global scope; when two files define the same name, the second silently overrides the first.
- The namespace pattern built with closures reduces the number of global names, but leaves isolation to the programmer’s discipline and does not declare dependencies in the code.
- A source parsed as a module keeps its top-level declarations in module scope; strict
mode is always on, and top-level
thisisundefined. - A module system provides four guarantees: isolation, an explicit interface, deterministic resolution, and single evaluation.
Next Step
Module scope keeps names from leaking out — but we have not yet defined what a module deliberately hands to another module. The next lesson takes up the language’s standard module syntax: the forms of export, the binding behavior of import, and why this syntax’s static structure is not just a matter of writing style.
To keep your progress and take notes, Log in
My notes
Log in to take notes.