Modules and Bundling
As applications grow, putting all your code in one file becomes unmanageable. Modules let you split code into separate files, each with its own scope, and then import only what you need. In this lesson you will learn the ES module system — the module system built into the JavaScript language itself, and what browsers use natively. (You'll still meet Node's older require system in a lot of existing code.)
What You'll Learn
By the end of this lesson you will split a program across two real files and run it — writing export in
one, import in the other, and node main.mjs in your terminal — and you will build the resolution-and-
caching engine that sits underneath that syntax, so the words stop being magic. You arrive able to write
functions and to reason about what a scope keeps alive (03-functions, 07-closures-and-scope); you
leave able to organize a growing codebase into files with explicit, checkable dependencies.
This lesson is not tied to a capstone milestone — the Task Manager is a single file, deliberately. What it is for is everything after: every real project you touch is many files, every bundler and test runner assumes this model, and the two mechanics below (imports resolve before any code runs, and a module is evaluated once no matter how often it is imported) are the ones that explain module bugs you would otherwise have to guess at.
How this lesson runs
Modules are the one topic on this track that genuinely needs more than one file, and the in-page runner gives you exactly one. So this lesson works in two registers, and the difference matters:
- The real thing, run by you. The section below gives you two complete files and the one command that
runs them. That is where you see actual
importandexportexecute, and it takes about a minute. - The mechanics, run here. Everything the syntax hides — name resolution, and the caching that makes a module evaluate once — is modelled in plain functions in the runnable blocks, so you can experiment with the behaviour without leaving the page. Where a block simulates a module with an object, it says so.
Why Modules?
Before modules, all JavaScript files shared the global scope. This caused naming collisions, made dependencies unclear, and made large codebases difficult to maintain. Modules solve these problems:
// The problem: global scope pollution
// Imagine these were separate script files loaded on a page
// file1.js
var name = "Alice";
var count = 10;
// file2.js (loaded later, overwrites file1's variables!)
var name = "Bob";
var count = 20;
console.log(name); // "Bob" - file1's name is gone!
console.log(count); // 20 - file1's count is gone!
// With modules, each file has its own scope
// No variables leak out unless explicitly exported
console.log("\nWith modules, each file is isolated:");
console.log("- file1.js exports: { name: 'Alice', count: 10 }");
console.log("- file2.js exports: { name: 'Bob', count: 20 }");
console.log("- No conflicts because each module has its own scope");
Run Two Real Modules First
Before any simulation, run the real thing once — it takes a minute and it makes everything below concrete.
Create a folder, put these two files in it side by side, and note the .mjs extension: it is what
tells Node to treat the file as an ES module rather than as an old-style script. (In a project with a
package.json containing "type": "module", plain .js works the same way.)
// mathUtils.mjs
export const PI = 3.14159;
export function add(a, b) {
return a + b;
}
export function square(n) {
return n * n;
}
console.log("mathUtils.mjs evaluated");
// main.mjs
import { add, PI } from "./mathUtils.mjs";
import { add as addAgain } from "./mathUtils.mjs";
console.log("PI:", PI);
console.log("add(5, 3):", add(5, 3));
console.log("same function on the second import:", add === addAgain);
Then, from that folder, run:
node main.mjs
What you will see, exactly these four lines:
mathUtils.mjs evaluated
PI: 3.14159
add(5, 3): 8
same function on the second import: true
Three things in that output are worth pausing on, and each one is a mechanism the rest of the lesson depends on.
mathUtils.mjs evaluated prints first, before any line of main.mjs — even though main.mjs is the
file you ran. That is the first mechanism: an ES module's imports are resolved and its dependencies
evaluated before the importing module's own code begins. Imports are not statements that run in place
when execution reaches them; they are declarations the loader satisfies up front. This is why a mistyped
import fails before your program does anything at all, and it is what the Predict block below turns on.
mathUtils.mjs evaluated prints exactly once, though main.mjs imports from it twice. That is the
second mechanism: a module is evaluated once per program run, and every importer is handed the same
exports. Import it from ten files and its top-level code still runs a single time — which is why a
module's setup (opening a connection, reading configuration) happens once no matter how widely it is used.
same function on the second import: true is the same fact from the other side: the second import did
not produce a fresh copy of add, it produced the very same function object. That is what "the same
exports" means concretely, and it is exactly the caching you will build for yourself in the Try It
Yourself task at the end.
Everything from here on is about what that syntax means. Because this page runs as a single file, the blocks below model modules with plain objects and functions — each one says so where it does.
Named Exports
Named exports let you export multiple values from a module. The importing code must use the exact same names (or rename them explicitly):
// Simulating a math utilities module
// In a real file: mathUtils.js
// Named exports (would use 'export' keyword in a real module)
const PI = 3.14159;
function add(a, b) {
return a + b;
}
function multiply(a, b) {
return a * b;
}
function square(n) {
return n * n;
}
// Simulating: export { PI, add, multiply, square };
const mathUtils = { PI, add, multiply, square };
// Simulating: import { add, multiply, PI } from './mathUtils.js';
const { add: importedAdd, multiply: importedMultiply, PI: importedPI } = mathUtils;
console.log("PI:", importedPI);
console.log("add(5, 3):", importedAdd(5, 3));
console.log("multiply(4, 6):", importedMultiply(4, 6));
// Simulating: import * as math from './mathUtils.js';
const math = mathUtils;
console.log("\nUsing namespace import:");
console.log("math.square(9):", math.square(9));
console.log("math.PI:", math.PI);
// Real module syntax reference:
console.log("\n--- Real syntax ---");
console.log("export const PI = 3.14159;");
console.log("export function add(a, b) { return a + b; }");
console.log('import { add, PI } from "./mathUtils.js";');
console.log('import * as math from "./mathUtils.js";');
The playground above simulates modules with a plain object because everything on this page runs as one file — real import/export only work across separate files. The next block reasons about that real, cross-file behavior, so it shows the code rather than running it.
Predict
greet.js has a DEFAULT export. main.js tries to pull it in with curly braces — { greet } — the syntax for a NAMED export. What happens when you run main.js with a real ES-module loader (like Node running .mjs files)?
// greet.js
export default function greet(name) {
return "Hello, " + name;
}
// main.js
import { greet } from "./greet.js";
console.log(greet("Ada"));Default Exports
Each module can have one default export. Default exports are convenient when a module's primary purpose is to export a single thing, like a class or a main function:
// Simulating default and named exports
// userService.js - default export is the class
function UserService(apiUrl) {
this.apiUrl = apiUrl;
this.users = [];
}
UserService.prototype.addUser = function(name, email) {
const user = { id: this.users.length + 1, name, email };
this.users.push(user);
return user;
};
UserService.prototype.findUser = function(id) {
return this.users.find(function(u) { return u.id === id; });
};
UserService.prototype.listUsers = function() {
return this.users;
};
// Also export a helper (named export)
function formatUser(user) {
return user.name + " <" + user.email + ">";
}
// Simulating: export default UserService;
// Simulating: export { formatUser };
// When importing a default, you can name it anything
// import UserService from './userService.js';
// import { formatUser } from './userService.js';
// import UserService, { formatUser } from './userService.js';
const service = new UserService("https://api.example.com");
service.addUser("Alice", "alice@example.com");
service.addUser("Bob", "bob@example.com");
console.log("All users:", service.listUsers());
console.log("User 1:", formatUser(service.findUser(1)));
console.log("User 2:", formatUser(service.findUser(2)));
console.log("\n--- Real syntax ---");
console.log("export default class UserService { ... }");
console.log("export function formatUser(user) { ... }");
console.log('import UserService from "./userService.js";');
console.log('import UserService, { formatUser } from "./userService.js";');
Debug
greet.js exports one thing and main.js imports it — and the program never runs at all. Because real import/export needs more than one file, both sides are modelled here by a tiny loader whose exports object has a default slot and a slot per named export, exactly as a real module's does. Commit a hypothesis about what the loader is being asked for and what it actually holds, then fix main's import.
const assert = require('assert');
// A tiny stand-in for a module loader, so the import/export MISMATCH can be
// observed on this page. A module's exports object has a `default` slot and a
// slot per named export, exactly as a real ES module does.
const modules = {};
function defineModule(name, exports) {
modules[name] = exports;
}
// Named import: import { thing } from "./mod.js" -> importNamed("mod", "thing")
function importNamed(name, exportName) {
const exports = modules[name];
if (!(exportName in exports)) {
throw new SyntaxError(
"The requested module '" + name + "' does not provide an export named '" + exportName + "'",
);
}
return exports[exportName];
}
// Default import: import thing from "./mod.js" -> importDefault("mod")
function importDefault(name) {
return modules[name].default;
}
// greet.js has ONE default export and no named exports.
defineModule("greet", {
default: function greet(who) {
return "Hello, " + who;
},
});
// main.js wants greet from it.
const greet = importNamed("greet", "greet");
assert.strictEqual(greet("Ada"), "Hello, Ada", "main should be able to call greet");
console.log(greet("Ada"));Expected output:
Hello, Ada
Re-exports and Barrel Files
Large projects use barrel files (usually named index.js) to re-export items from multiple modules, creating a clean public API:
// Simulating a barrel file pattern
// models/user.js
const User = { type: "User", fields: ["name", "email"] };
// models/product.js
const Product = { type: "Product", fields: ["name", "price"] };
// models/order.js
const Order = { type: "Order", fields: ["userId", "productIds", "total"] };
// models/index.js (barrel file) - re-exports everything
// export { User } from './user.js';
// export { Product } from './product.js';
// export { Order } from './order.js';
const models = { User, Product, Order };
// Now consumers import from one place
// import { User, Product, Order } from './models';
// instead of:
// import { User } from './models/user';
// import { Product } from './models/product';
// import { Order } from './models/order';
console.log("Barrel file exports:");
Object.keys(models).forEach(function(key) {
console.log(" " + models[key].type + ": " + models[key].fields.join(", "));
});
console.log("\n--- Real barrel file syntax ---");
console.log("// models/index.js");
console.log('export { User } from "./user.js";');
console.log('export { Product } from "./product.js";');
console.log('export { Order } from "./order.js";');
console.log("");
console.log("// consumer.js");
console.log('import { User, Product, Order } from "./models";');
Ordering a require
The Try It Yourself task at the end asks you to build this for real. First, get its order straight. Here is the setting — a registry of factories, a cache, and a log of which modules actually evaluated:
const registry = {
config: () => {
evaluations.push("config");
return { url: "https://api.example.com" };
},
};
const cache = {};
const evaluations = [];
function requireModule(name) {
if (Object.hasOwn(cache, name)) return cache[name];
// the five lines of the block below go here
}
Arrange the code
Reassemble the rest of requireModule's body — the part that runs on a cache MISS. It must throw for a name that was never registered, evaluate a registered module's factory, remember the result so the next call is a hit, and hand back the exports. Every wrong arrangement here is observable: some crash, and one runs clean while evaluating the module twice. Why must these five lines run in this order and no other?
if (!factory) throw new Error("unknown module: " + name);return exports;cache[name] = exports;const factory = registry[name];const exports = factory();
Dynamic Imports
Static imports are loaded at the top of a file before any code runs. Dynamic imports load modules on demand, which is essential for performance because users only download code when they need it:
// Simulating dynamic imports
// In real code: const module = await import('./heavyModule.js')
function simulateImport(moduleName, delay) {
return new Promise(function(resolve) {
console.log("Loading " + moduleName + "...");
setTimeout(function() {
const fakeModule = {
name: moduleName,
default: function() { return moduleName + " is ready"; },
version: "1.0.0"
};
console.log(moduleName + " loaded!");
resolve(fakeModule);
}, delay);
});
}
// Load modules on demand (like clicking a button)
async function loadFeature(featureName) {
try {
const module = await simulateImport(featureName, 500);
console.log("Result:", module.default());
console.log("Version:", module.version);
} catch (error) {
console.log("Failed to load:", error.message);
}
}
async function main() {
console.log("App started - only core modules loaded\n");
// These would be triggered by user actions
await loadFeature("ChartLibrary");
console.log("");
await loadFeature("PDFExporter");
console.log("\n--- Real dynamic import syntax ---");
console.log('const { Chart } = await import("./charts.js");');
console.log('const module = await import(`./features/${name}.js`);');
}
main();
Module Design Patterns
Well-designed modules follow certain patterns that make them easy to use, test, and maintain:
// Pattern 1: Single Responsibility
// Each module does one thing well
const Logger = (function() {
const logs = [];
return {
info(message) { logs.push({ level: "INFO", message, time: Date.now() }); console.log("[INFO] " + message); },
warn(message) { logs.push({ level: "WARN", message, time: Date.now() }); console.log("[WARN] " + message); },
error(message) { logs.push({ level: "ERROR", message, time: Date.now() }); console.log("[ERROR] " + message); },
getHistory() { return [...logs]; }
};
})();
// Pattern 2: Factory Functions
// Export functions that create configured instances
function createValidator(rules) {
return {
validate(data) {
const errors = [];
rules.forEach(function(rule) {
if (!rule.test(data[rule.field])) {
errors.push(rule.field + ": " + rule.message);
}
});
return { valid: errors.length === 0, errors };
}
};
}
// Pattern 3: Constants Module
// Centralize configuration
const CONFIG = Object.freeze({
API_URL: "https://api.example.com",
MAX_RETRIES: 3,
TIMEOUT: 5000,
ITEMS_PER_PAGE: 20
});
// Using the modules together
Logger.info("Starting application");
Logger.info("API URL: " + CONFIG.API_URL);
const emailValidator = createValidator([
{ field: "email", test: function(v) { return v && v.includes("@"); }, message: "Must be valid email" },
{ field: "name", test: function(v) { return v && v.length >= 2; }, message: "Must be at least 2 chars" }
]);
const result = emailValidator.validate({ email: "test@test.com", name: "Al" });
if (result.valid) {
Logger.info("Validation passed");
} else {
result.errors.forEach(function(e) { Logger.warn(e); });
}
console.log("\nLog history:", Logger.getHistory().length, "entries");
Recall
Without scrolling up: the Logger above is written as const Logger = (function() { ... })() — an IIFE that runs once and returns an object of methods, keeping its logs array private. You met this exact shape back in 07-closures-and-scope. What does that IIFE-plus-closure give the module that a real ES module file (with export) also gives it?
Try It Yourself
Reading about import and export is not the same as building the machinery beneath them. This is a build task: a small program that reports its own pass/fail. But there is a catch that this whole page has been working around — real import/export only run across separate files, and everything here runs as one file, which is why every example above shows the module syntax rather than executing it.
One mechanic the syntax hides is worth stating plainly first: a real module loader caches. Import the same module from ten different files and its top-level code runs exactly once — every importer is handed the very same exports object, not a fresh copy each time. That is why a module's setup (opening a connection, reading config) happens a single time no matter how widely it is imported.
So instead of the syntax, you will build that mechanic, modeled in plain functions: a tiny module registry. defineModule(name, factory) registers a module by name; requireModule(name) resolves it by name and — the key part — evaluates each module's factory at most once, then caches and hands back the same exports on every later call, exactly as the loader above does with your files. (The IIFE module pattern from 07-closures-and-scope and the Logger above solve a different problem — file-level privacy — so this caching behavior is genuinely new here.) The syntax stays in the no-run examples; the mechanics you can actually run.
Build
Finish the build. Three functions are stubbed out and the checks below them fail until each one behaves correctly. Run it as-is to see which check fails first, decide what that function is missing, then implement the three functions until it prints 'All checks passed.' The checks run top to bottom, so the first failure you see is TODO 1 — implement it first, then work down.
const assert = require('assert');
// A tiny module system, modeled in plain functions. The import/export SYNTAX
// stays in the no-run examples above; here we build the MECHANICS underneath it:
// register a module by name, resolve it by name, and evaluate each factory at
// most once (then hand back the cached exports). Do NOT change these two stores.
const registry = {}; // name -> factory function (returns the exports)
const cache = {}; // name -> the already-evaluated exports
// Membership is checked with Object.hasOwn(store, name), which checks the
// object's OWN keys only; `in` would also see inherited names such as toString,
// making every Object.prototype member look like a module somebody registered.
// TODO 1: register a module. Store factory under name in the registry
// (registry[name] = factory), so Object.hasOwn(registry, name) becomes true.
// defineModule("greet", () => "hi") -> Object.hasOwn(registry, "greet") === true
function defineModule(name, factory) {
// your code here
}
// TODO 2: resolve a module by name. If it was resolved before, return the
// cached exports. Otherwise run its factory ONCE, cache the result, return it.
// Throw an Error("unknown module: " + name) if the name was never defined.
// requireModule("greet") -> "hi" (runs greet's factory only the first time)
function requireModule(name) {
// your code here
}
// TODO 3: return true if a module has already been evaluated (its exports are
// cached), false otherwise. A defined-but-never-required module is false.
// isEvaluated("greet") -> false before first require, true after
function isEvaluated(name) {
// your code here
}
// --- Build checks: these must all pass. Do not edit below this line. ---
// Track how many times each factory actually runs, to prove single-evaluation.
let configRuns = 0;
// TODO 1: defineModule must record the factory so the registry knows it exists.
defineModule("config", () => {
configRuns += 1;
return { apiUrl: "https://api.example.com" };
});
assert.strictEqual(
Object.hasOwn(registry, "config"),
true,
"defineModule should register the module by name (Object.hasOwn(registry, name) becomes true)",
);
// TODO 3: a defined-but-never-required module has not been evaluated yet.
assert.strictEqual(
isEvaluated("config"),
false,
"isEvaluated should be false for a module that was defined but never required",
);
// TODO 2: require resolves the name by running its factory and returning the exports.
const config = requireModule("config");
assert.deepStrictEqual(
config,
{ apiUrl: "https://api.example.com" },
"requireModule should return the exports produced by the module's factory",
);
assert.strictEqual(
isEvaluated("config"),
true,
"isEvaluated should be true once a module has been required",
);
// TODO 2: a second require returns the SAME cached object, and does NOT re-run the factory.
const configAgain = requireModule("config");
assert.strictEqual(
configAgain,
config,
"requireModule should return the SAME cached exports on a second call",
);
assert.strictEqual(
configRuns,
1,
"a module's factory must run exactly once, no matter how many times it is required",
);
// TODO 2: modules resolve independently by name, and an unknown name throws.
defineModule("logger", () => {
const prefix = "[app] ";
return { log: (msg) => prefix + msg };
});
const logger = requireModule("logger");
assert.strictEqual(
logger.log("hello"),
"[app] hello",
"requireModule should resolve each module independently by its name",
);
assert.throws(
() => requireModule("nope"),
/unknown module: nope/,
"requireModule should throw for a name that was never defined",
);
console.log("All checks passed.");
console.log("config:", requireModule("config"));
console.log("config factory runs:", configRuns);
console.log("logger.log('hello'):", requireModule("logger").log("hello"));Expected output:
All checks passed.
config: { apiUrl: 'https://api.example.com' }
config factory runs: 1
logger.log('hello'): [app] helloOnce it passes, try two variations and predict each before running:
- Redefine after require. After
requireModule("config")has already run once, calldefineModule("config", ...)again with a different factory that returns a different value, thenrequireModule("config")a second time and log it. Write down which value comes back — the old one or the new one — and whether the new factory runs at all, before running. (Output-changing: the logged value is what you are predicting.) - A module that requires another module. Define a
"client"whose factory callsrequireModule("config")and returns{ url: config.apiUrl + "/users" }, then require"client"and logisEvaluated("config")both before and after. Write down the two boolean lines, and how many times config's factory runs, before running. (Output-changing: you are predicting the twoisEvaluatedlines.)
After you have written your prediction down, check it against what actually happens.
What you should have seen
- The old value comes back and the new factory never runs at all. The cache is checked before the
registry, so a name that is already evaluated never re-resolves — the second
defineModulesilently changes nothing that anyone will observe. This is a real hazard in real loaders: editing a module after something has imported it does not un-import it. falsethentrue, and config's factory runs exactly once. Resolvingclientpulls its dependency in during that first evaluation, and that pull goes through the same cache as any other require — which is why a diamond of four modules all importing one shared module still evaluates the shared one once.
Transfer
The mechanism you just built is name resolution: an importer names something, and a loader decides at load time whether that name exists in the named module, refusing to run if it does not. TypeScript adds a second resolver over the same import lines, one that runs before the program is ever executed and answers a different question about the same names. Which statement gets both the shared invariant and the difference right?
Key Takeaways
- Modules give each file its own scope, preventing global variable conflicts
- Named exports let you export multiple values; default exports are for one main value per module
- Use
import { name } from './module'for named exports andimport Name from './module'for defaults - Barrel files re-export from multiple modules for a clean public API
- Dynamic imports with
await import()load code on demand for better performance - Good module design follows single responsibility and creates clear public interfaces
Next Steps
Now that you can organize code into modules, the next lesson covers testing and debugging. You will learn how to verify your modules work correctly and track down bugs efficiently.
Pro Tip: Keep modules small and focused. If a module is doing too many things, split it up. A good rule of thumb: if you cannot describe what a module does in one sentence, it probably needs to be broken into smaller pieces.
Next lesson
Testing and Debugging
Learn JavaScript testing and debugging. Write unit tests, use TDD, master console methods, and build reliable debugging strategies.
24 min