Skip to content
academia.sh

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 this is undefined.
  • 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.

Start typing to search.

↑↓ Esc navigate · open · close