Modules, packages, and the toolchain · Lesson 60 of 77 · concept
What a module is
About 55 minutes.
By the end of this lesson you can
- Say what problem splitting a program into modules solves, in your own words.
- Explain module scope: what a file keeps to itself and what it publishes.
- Read an import line and say which file it names and what it takes from that file.
- Explain why a module's body runs once however many files import it.
- Model a module's public surface with a factory function, and say where the model breaks.
Everything you have written so far lived in one file
Why it matters
Every program in this course so far has been a single stretch of code. That is fine while a program fits on a screen. Real projects do not. A working app is thousands of lines, and the moment you pass a few hundred, three problems arrive together and never leave.
The first is finding anything. You know the invoice total is calculated somewhere. Scrolling is not a search strategy. The second is name collisions: two parts of the program both want a variable called total, and in one shared file only one of them can have it. The third is the worst, and it is the one that actually costs money: nobody can tell which parts of the code are safe to change. If every function can be called from anywhere, then changing any function might break anything.
Modules are the answer to all three at once, and the answer is almost boringly simple: put related code in its own file, and make that file decide what the rest of the program is allowed to see.
A workshop with a serving hatch
An analogy
Picture a workshop with the door locked and one small hatch in the wall. Inside there are tools, half-finished parts, notes to self, a kettle. None of it is visible from outside. The only things the outside world ever sees are the finished items the workshop chooses to push through the hatch, and it labels each one on the way out.
A module is that workshop, and a file is its walls. Everything you declare in the file is inside: helper functions, constants, the messy intermediate steps. Nothing leaves unless you put it through the hatch deliberately. The word for pushing something through the hatch is export, and the word for taking something from another workshop's hatch is import.
The value of the hatch is not secrecy for its own sake. It is that the hatch is a promise. What goes through it is what other files depend on, so it is what you have to keep working. Everything else you can rip out and rewrite this afternoon, because by construction nobody else can be relying on it.
Module scope: private by default
Explanation
In module 3 you met scope: a variable declared inside a function is invisible outside it. A module adds one more ring of exactly the same kind. Names declared at the top level of a module file belong to that module, not to the whole program, and they are not reachable from another file — not even by typing the right name — unless the module exports them.
This is worth sitting with, because it is the opposite of what a beginner usually assumes. Loading two files does not pour their variables into one shared pot. Each file keeps its own. That is why you can have a helper called format in a dozen different modules and never once collide.
So there are exactly two kinds of name in a module: the ones you export, which are its public surface and part of its contract, and everything else, which is private. There is no third category and no accidental middle ground. If you did not export it, it did not leave the room.
The two lines you actually type
Explanation
To publish, put the word export in front of a declaration — export function toFahrenheit(celsius) { ... } — or list names at the bottom of the file: export { toFahrenheit, isFreezing };. Both do the same job. The list form has the small advantage that a reader can see the whole public surface in one place.
To consume, write import { toFahrenheit } from "./temperature.js"; at the top of the file that needs it. The braces are not an object literal even though they look like one. They are a list of names you are picking out of that module's hatch, and each name has to match a name the other file actually exported. Spelling counts. Case counts.
The string after from is called the module specifier, and it comes in two flavours you will meet constantly. A relative specifier starts with ./ or ../ and points at a file by path, the way a link on a web page does; in the browser it includes the file extension. A bare specifier is a plain name like "date-fns", with no dots and no slashes at the front, and it does not mean a file in your project at all — it means a package, looked up in the node_modules folder. Lesson three of this module is about where those come from.
Check there: The page defines relative, absolute and bare module specifiers, and states that bare specifiers are resolved in the node_modules directory by the CommonJS convention.
A module's body runs once, and everyone gets the same one
A mental model
Here is the fact that surprises people, and the one that makes modules genuinely useful rather than just tidy: the code in a module file runs exactly once, the first time anything imports it. If twenty other files import it afterwards, they do not each get a fresh copy. They all get the same one that already exists.
So if a module creates a configuration object, a cache or a counter at its top level, that object is shared across the entire program. One instance, one state, one truth. This is a feature, and it is also the source of a whole family of confusing bugs: if module A changes something on that shared object, module B sees the change, even though B never asked anybody to change anything.
The mental model to keep: importing is not copying. It is being handed a reference to something that already exists. MDN puts the same point another way when it says imported bindings are live — the importing file sees the exporting file's current value, and cannot reassign it.
Imports are decided before your code runs
Explanation
An import declaration may only appear at the top level of a module. Not inside an if, not inside a function, not halfway down. Try it and you get a syntax error before a single line executes.
That restriction can feel arbitrary until you see what it buys. Because every import sits at the top level and names its source with a plain string, a tool can read your files WITHOUT running them and work out the entire graph of what depends on what. That is what makes bundling, tree shaking and type checking possible at all, and it is why lesson five of this module keeps coming back to the word static.
It also means the imports are set up before the body of your file starts running. MDN describes import declarations as hoisted: the imported values are usable anywhere in the module, and the imported file's own top-level code has already run by the time yours starts. Putting your imports at the top is convention rather than a requirement, but the convention matches what actually happens.
Why you cannot run this lesson's syntax here, and what we use instead
Explanation
The code runner on this page is a small sealed JavaScript engine with no file system, no network and no module loader. It cannot open a second file, because there is no second file. So an import line fails outright, and a program containing export confuses the runner about what your program's answer even was. This module is the one place in the course where the honest thing to say is: read this, do not run it.
What we can run is a model. A function that declares some private variables and then returns an object of functions gives you the same two-part shape a module has: a private inside and a published outside. You already know how to write one — it is the closure from module 3 wearing a different hat. Throughout these two lessons a function called createSomethingKit stands in for a file, and the object it returns stands in for that file's exports.
Where the model is accurate: what is inside stays inside, and only what you put on the returned object can be reached from outside. Where it stops being accurate: a real module body runs ONCE and everybody shares the result, whereas calling a factory twice builds two separate worlds that know nothing about each other. Keep that difference in view — the third worked example below shows both halves of it in nine lines.
Four mistakes that cost beginners an evening each
A common mistake
"Cannot use import statement outside a module". The syntax is fine; the file was loaded as a plain script instead of a module. In a browser that means the script tag needs type="module"; in Node it means the nearest package.json needs "type": "module" or the file needs a .mjs extension. Lesson three comes back to this, because it is a project-configuration problem wearing an error message's clothing.
Forgetting the file extension. In the browser, "./temperature" is not the same request as "./temperature.js" and will simply 404. Some tools paper over this for you, which is worse rather than better, because the code then works in one place and not another.
Expecting a variable to work as a specifier. import { x } from someName is not valid: the source has to be a literal string, precisely so tools can read it without running it. When you genuinely need to choose a module at runtime there is a separate dynamic form, import(), which is a different tool for a different job and is not what this lesson is about.
Assuming an import copies the file's code into yours. It does not. It hands you a reference into a module that has already been evaluated, once, somewhere else. If you find yourself reasoning "but my file does not change it", check whether some other file shares the same instance.
Recap
Recap
A module is a file that keeps everything to itself except what it exports. Other files reach it with import, naming it either by relative path or as a bare package name. Its body runs once, and every importer shares that one evaluation; imports are references, not copies. Import declarations live at the top level and name their source with a literal string, which is what lets tools read your project without running it. Here, where there is no module loader, a factory function that returns an object of functions models the same private-inside, published-outside shape.
Worked examples
Every output below was produced by actually running the code in the same sandbox your exercises run in — it is not a prediction.
Two files, one hatch
The smallest complete example of the real syntax: one file that keeps a constant private and publishes two functions, and one file that consumes them. Read the last line as the point of the whole lesson, not as an accident.
1// --- file: src/temperature.js -----------------------------------2const FREEZING_C = 0;3 4function toFahrenheit(celsius) {5 return celsius * 1.8 + 32;6}7 8function isFreezing(celsius) {9 return celsius <= FREEZING_C;10}11 12export { toFahrenheit, isFreezing };13 14// --- file: src/report.js ----------------------------------------15import { toFahrenheit, isFreezing } from "./temperature.js";16 17console.log(toFahrenheit(20));18console.log(isFreezing(-3));19console.log(FREEZING_C);- line 2
Declared at the top level of temperature.js and never exported, so this name does not exist as far as any other file is concerned.
- line 9
Inside its own module, FREEZING_C is an ordinary variable. Privacy is about who can reach in from outside, not about who can use it in here.
- line 12
The hatch. This one line is the file's entire public surface, and a reader can check it in a second without reading the rest.
- line 15
The braces are a list of names to take, not an object. Each name must match one the other file exported, spelling and case included. The specifier is a relative path, extension and all.
- line 19
This throws a ReferenceError. FREEZING_C was never exported, so importing the module does not bring the name along - and that is the guarantee, not a limitation.
This one does not run hereThe runner on this page has no module loader: an import line fails outright, and a program containing export changes what the runner reports back as your program's result. This is real ES module syntax to read, not to run here.
The same shape, in code that runs here
A factory function is the runnable model of a module: private state above, a published object below. Watch line 21 especially - reaching for something that was never put on the returned object gives you undefined, exactly as reaching for a name that was never exported gives you nothing.
1function createSettings(startingTheme) {2 let theme = startingTheme;3 4 function setTheme(next) {5 theme = next;6 return theme;7 }8 9 function currentTheme() {10 return theme;11 }12 13 return { setTheme, currentTheme };14}15 16const settings = createSettings("light");17 18console.log(settings.currentTheme());19console.log(settings.setTheme("dark"));20console.log(settings.currentTheme());21console.log(settings.theme);22console.log(Object.keys(settings).join(", "));- line 2
The module's private state. Nothing outside this function can read or write it directly; the only way to touch it is through the two functions below.
- line 13
The hatch again, in object form. These two names are the public surface - the equivalent of 'export { setTheme, currentTheme }'.
- line 16
Calling the factory once is the model of a module being evaluated once. The object it returns is what other code holds on to.
- line 21
theme was never published, so it is not a property of the returned object. You get undefined rather than an error, which is JavaScript's usual answer for a missing property.
- line 22
Object.keys lists exactly what was published. If you are ever unsure what a kit exposes, this line answers it.
What it prints
lightdarkdarkundefinedsetTheme, currentTheme
One evaluation, shared - and where the model stops
The half of module behaviour the factory models well, and the half it does not. Two functions holding the same kit see each other's changes, which is exactly how a real module's shared state behaves. Then line 28 does something no module can do: it starts a second, unrelated world.
1function createSettings(startingTheme) {2 let theme = startingTheme;3 return {4 setTheme: function (next) {5 theme = next;6 return theme;7 },8 currentTheme: function () {9 return theme;10 },11 };12}13 14const settings = createSettings("light");15 16function header(kit) {17 return "header is " + kit.currentTheme();18}19 20function footer(kit) {21 return "footer is " + kit.currentTheme();22}23 24settings.setTheme("dark");25console.log(header(settings));26console.log(footer(settings));27 28const otherSettings = createSettings("light");29console.log("second copy is", otherSettings.currentTheme());- line 14
This single call is the model of the module body running once. In a real project this line does not exist in your code - the module system does it for you, the first time anybody imports the file.
- line 24
One holder changes the state. Because header and footer were handed the same kit, both see the change on the next line. That is what 'one shared instance' means in practice.
- line 28
Here the stand-in and the real thing part company. Calling the factory again builds a second, independent kit with its own private theme. A real module cannot be re-evaluated like this; every importer gets the one that already exists.
What it prints
header is darkfooter is darksecond copy is light
Check yourself
Not scored, not stored. Getting one wrong is the useful part.
temperature.js declares FREEZING_C and two functions, and ends with 'export { toFahrenheit, isFreezing };'. Another file imports that module. What can it reach?
createStore keeps a count in a variable and returns an object with one function on it. Work out all three lines before you look - the third is the one worth pausing on.
function createStore() { let items = 0; return { add: function () { items = items + 1; return items; }, };}const store = createStore();console.log(store.add());console.log(store.add());console.log(store.items);
config.js creates an object at its top level. Five different files import it. How many objects exist?
Write it yourself
Exercise · core · 9 tests
Build a module-shaped counter
Write a factory function createCounterModule(startAt) that models a module: private state inside, a small published surface outside. It must return an object with exactly three functions and nothing else - increment(), which adds one to the count and returns the new value; reset(), which puts the count back to startAt and returns it; and read(), which returns the current count without changing it. The count itself must NOT appear on the returned object. The starter code publishes the count as a property and uses 'this' to reach it, which is the shape most people reach for first; it passes some tests and fails the ones that matter. Fixing it means moving the count into the factory's own scope so the three functions close over it.
createCounterModule(startAt) returns a new object each call, with exactly the keys increment, reset and read, all functions. increment adds one and returns the new count; read returns it unchanged; reset returns to startAt. The count is not readable as a property. Each of the three functions must still work when it is pulled off the object and called on its own, the way an imported function is.
Define createCounterModule at the top level of your code — the tests call it by name.
Worth remembering
- What can another file reach in a module you wrote?
Exactly the names you exported. Everything else declared in the file is private to it; loading a module never merges its names into yours.
- Ten files import config.js. How many times does its body run?
Once. The result is cached and shared, so top-level state in a module is shared program-wide - a feature and a hazard at the same time.
- What is the difference between importing from "./format.js" and from "date-fns"?
The first is a relative specifier: a file in your project, found by path. The second is a bare specifier: a package, resolved in node_modules.
Check this lesson against the source
We wrote the explanation; we did not invent the facts. This is the page that backs them.
Check there: Module features are scoped to the module rather than the global scope and must be exported to be used elsewhere; modules use strict mode automatically; and a module is only executed once however many times it is referenced.
Check there: Both the inline 'export function ...' form and the 'export { a, b }' list form are documented, along with renaming via 'as'.
Check there: Import declarations may only appear at the top level of a module, and imported bindings are live and read-only in the importing module.
Prefer to read the source in a structured breakdown? Turn this doc into a breakdown