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
- Why modules matter for code organization
- ES module syntax:
importandexport - Default exports vs named exports
- Dynamic imports for code splitting
- Module patterns and best practices
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");
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";');
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";');
Arrange the code
Assemble three files that build up to a barrel import: logger.js defines a PREFIX constant and a log function that reads it, utils/index.js is the barrel that re-exports log, and main.js imports log from the barrel and calls it. Follow the top-down reading order used throughout this lesson: put each declaration above the code that refers to it.
export { log } from "../logger.js";log("started");}import { log } from "./utils/index.js";const PREFIX = "[app]";// main.jsconsole.log(PREFIX + " " + msg);export function log(msg) {// logger.js// utils/index.js (barrel file)
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.
// (A Map is a key-value store like a plain object, with .set/.get/.has methods.)
const registry = new Map(); // name -> factory function (returns the exports)
const cache = new Map(); // name -> the already-evaluated exports
// TODO 1: register a module. Store factory under name in the registry
// (registry.set(name, factory)), so registry.has(name) becomes true.
// defineModule("greet", () => "hi") -> registry.has("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(
registry.has("config"),
true,
"defineModule should register the module by name (registry.has(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] hello
Once 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. Predict which value comes back and whether the new factory runs at all — the cache is checked before the registry, so a name that is already evaluated never re-runs, and the second define silently changes nothing. (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. Predict the two boolean lines and how many times config's factory runs — resolvingclientpulls its dependency in on first evaluation, and that pull is itself cached. (Output-changing: you are predicting the twoisEvaluatedlines.)
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