JavaScript Modules: What Modules Are · Exports · Imports · Namespaces · Dynamic Imports
How to use this tutorial Modules are the foundation of every modern JavaScript application. They turn a single giant script file into an organised codebase of cooperating, reusable pieces — the same system that powers React, Vue, Node.js, and every npm package you will ever use. Work through all five chapters in order.
- Phase 1 – Comprehension: Full explanations, line-by-line walkthroughs, real-world analogies, thinking questions
- Phase 2 – Practice: Real-world exercises with warm-ups, hints, and self-checks
- Phase 3 – Creation: A full multi-stage project bringing all concepts together
TABLE OF CONTENTS
- Chapter 1 — What Are Modules?
- Chapter 2 — Exports
- Chapter 3 — Imports
- Chapter 4 — Namespaces
- Chapter 5 — Dynamic Imports
- Phase 2 — Applied Exercises
- Phase 3 — Project Simulation
- Quiz & Completion Checklist
CHAPTER 1 — WHAT ARE MODULES?
The Problem Modules Solve
Imagine you are building a shopping website. Over several months, your app.js grows to 4,000 lines. It contains helper functions, class definitions, API calls, UI logic, cart calculations, and user authentication — all tangled together. Every developer must read the entire file to find anything. One wrong edit can break something completely unrelated.
This is the global scope problem — one of the biggest sources of bugs in large JavaScript applications.
Real-world analogy — a kitchen: Imagine a professional kitchen where every utensil, ingredient, and piece of equipment is thrown in one giant pile in the middle of the room. Every cook must dig through everything to find anything. Now imagine the same kitchen with labelled drawers, shelves, and stations. Each station has exactly what it needs and nothing more. That organised kitchen is a modular codebase.
1.1 — What Is a Module?
A module is a JavaScript file that:
- Explicitly declares what it provides to other files (using
export) - Explicitly declares what it needs from other files (using
import) - Has its own private scope — variables and functions inside are invisible to the outside world unless exported
Before modules — everything global, everything tangled:
app.js
function calculateTax() ← visible everywhere, can be overwritten
function formatCurrency() ← visible everywhere
class ShoppingCart ← visible everywhere
const API_KEY = "secret" ← visible everywhere — dangerous!
... 3,990 more lines ...
After modules — each file does one job:
utils/math.js exports: add, subtract, calculateTax
utils/format.js exports: formatCurrency, formatDate
cart/Cart.js exports: ShoppingCart class
api/client.js exports: get, post, delete
main.js imports only what it needs from each
1.2 — Key Properties of Modules
| Property | Description |
|---|---|
| Own scope | Variables inside are private unless exported |
| Strict mode by default | Module code always runs in strict mode |
| Single evaluation | A module’s code runs only once, regardless of how many files import it |
| Static analysis | Import/export relationships are declared at top — tools can analyse them without running the code |
| Deferred by default | <script type="module"> executes after HTML is fully parsed |
| HTTPS or localhost required | Modules use CORS — they won’t load from file:// |
1.3 — Enabling Modules in the Browser
<!DOCTYPE html>
<html>
<body>
<!-- Regular script — shares global scope, no import/export -->
<script src="app.js"></script>
<!-- Module script — isolated scope, import/export available -->
<script type="module" src="main.js"></script>
</body>
</html>
<script> vs <script type="module">:
| Feature | <script> |
<script type="module"> |
|---|---|---|
| Scope | Global | Module-local (private) |
| Strict mode | Off by default | Always on |
import/export |
Not available | Available |
| Execution timing | Blocking (unless defer) |
Always deferred |
| Loads again on re-encounter | Yes | No — cached after first load |
| Requires CORS | No | Yes |
1.4 — Modules in Node.js
CommonJS (older — still the default in Node.js):
// Exporting:
module.exports = { add, subtract };
// Importing:
const { add, subtract } = require("./math");
const fs = require("fs"); // Built-in module
ES Modules (modern — same syntax as browser):
// In package.json: { "type": "module" } OR use .mjs file extension
// Exporting:
export function add(a, b) { return a + b; }
// Importing:
import { add } from "./math.js";
import fs from "node:fs";
💡 This tutorial focuses on ES Modules (ESM) — the modern standard that works in both browsers and modern Node.js, and is used by all major frameworks (React, Vue, Svelte) and build tools (Vite, Webpack, Rollup).
1.5 — The Module Map: Why Modules Execute Only Once
When the browser (or Node.js) encounters an import, it checks an internal module map — a registry of every path already loaded:
First time "import { add } from './math.js'" is seen:
→ Load math.js, parse it, run it, cache result in module map
Second time (a different file also imports math.js):
→ Check module map → already there → return cached exports immediately
→ math.js code does NOT run again
Why this matters — shared live state:
// counter.js
let count = 0;
export function increment() { count++; }
export function getCount() { return count; }
// fileA.js
import { increment } from "./counter.js";
increment(); // count becomes 1
// fileB.js
import { getCount } from "./counter.js";
console.log(getCount()); // Output: 1 — same module instance!
fileA and fileB share the same live counter.js module instance. There is only one count variable across the whole application.
🤔 Thinking question: If modules loaded fresh every time they were imported, what would
getCount()infileB.jsreturn, and why would that cause problems in a real app?
CHAPTER 2 — EXPORTS
What Is an Export?
An export is how a module declares what it is willing to share with the rest of the application. Everything not exported is private — completely invisible to importing files, as if it does not exist.
Think of a module as a company. Inside the company there are internal processes, private data, and trade secrets. What the company offers to the public — its products and services — is its public API. That public API is the export.
2.1 — Named Exports
Named exports let you export multiple things from one module, each with its own name. Importing files must use those exact names (or rename them — see Chapter 3).
Syntax 1 — export keyword inline:
// math.js
export const PI = 3.14159265;
export function add(a, b) {
return a + b;
}
export function multiply(a, b) {
return a * b;
}
export class Vector {
constructor(x, y) {
this.x = x;
this.y = y;
}
magnitude() {
return Math.sqrt(this.x ** 2 + this.y ** 2);
}
}
Syntax 2 — export list at the bottom (preferred for readability):
// math.js — all internal definitions first, public API declared at the bottom
const PI = 3.14159265;
function add(a, b) { return a + b; }
function subtract(a, b) { return a - b; }
function multiply(a, b) { return a * b; }
function divide(a, b) {
if (b === 0) throw new Error("Division by zero.");
return a / b;
}
class Vector {
constructor(x, y) { this.x = x; this.y = y; }
magnitude() { return Math.sqrt(this.x ** 2 + this.y ** 2); }
}
// One clear "table of contents" — everything the module provides:
export { PI, add, subtract, multiply, divide, Vector };
💡 Why list exports at the bottom? Having all exports in one
export { }block gives you a clean, scannable public API declaration without reading through every line of implementation. Many professional style guides prefer this pattern.
2.2 — Renaming at Export
You can rename values as they are exported, separating internal names from the public API:
// database.js
function connectToDatabase(connectionString) {
return { connected: true, url: connectionString };
}
function disconnectFromDatabase() {
return { connected: false };
}
// Expose shorter, cleaner public names:
export {
connectToDatabase as connect,
disconnectFromDatabase as disconnect
};
Consumers will import connect and disconnect, not the longer internal names.
2.3 — Default Export
Every module can have one default export — the “main thing” the module provides. Typically used when the entire purpose of a file is one function or one class.
// greeting.js
export default function greet(name) {
return "Hello, " + name + "!";
}
Default export with a class:
// ShoppingCart.js
export default class ShoppingCart {
#items = [];
add(item) { this.#items.push(item); return this; }
remove(itemId) { this.#items = this.#items.filter(i => i.id !== itemId); return this; }
get total() { return this.#items.reduce((sum, i) => sum + i.price, 0); }
get itemCount() { return this.#items.length; }
}
Default export with an expression:
// config.js
export default {
apiUrl: "https://api.myapp.com",
timeout: 5000,
retries: 3
};
2.4 — Default vs Named Exports — When to Use Each
| Situation | Use |
|---|---|
| Module has ONE primary purpose | Default export |
| Module provides a collection of utilities | Named exports |
| One class/component per file | Default export |
Utility libraries (math.js, format.js) |
Named exports |
| Configuration objects | Default export |
| Constants and enums used across files | Named exports |
💡 You can mix both in the same file:
// api.js export const BASE_URL = "https://api.myapp.com"; // Named export const TIMEOUT = 5000; // Named export default async function request(path, options) { // Default const res = await fetch(BASE_URL + path, options); if (!res.ok) throw new Error(res.status); return res.json(); }
2.5 — Re-Exporting: Building Barrel Files
A module can import from one file and immediately re-export — creating a centralised entry point called a barrel file or index file:
// utils/index.js — barrel file
export { add, subtract, multiply } from "./math.js";
export { formatCurrency, formatDate } from "./format.js";
export { capitalise, truncate } from "./string.js";
// Re-export a default as a named export:
export { default as request } from "./api.js";
// Re-export everything from a module:
export * from "./validators.js";
Now consumers import from one path:
// ✅ Clean — one import from the barrel:
import { add, formatCurrency, capitalise, request } from "./utils/index.js";
// ❌ Messy without a barrel — separate import for each file:
import { add } from "./utils/math.js";
import { formatCurrency } from "./utils/format.js";
import { capitalise } from "./utils/string.js";
import request from "./utils/api.js";
2.6 — What Cannot Be Exported
// ❌ Cannot export bare literals:
export 42; // SyntaxError
export "hello"; // SyntaxError
// ✅ Bind to a variable first:
export const ANSWER = 42;
// ❌ Cannot have more than one default:
export default function a() {}
export default function b() {} // SyntaxError — only one default per module
CHAPTER 3 — IMPORTS
What Is an Import?
An import is how a module declares its dependencies — what it needs from other modules to do its work. Static imports are resolved before the code runs, always declared at the top of a file.
3.1 — Importing Named Exports
Use curly braces { } to import named exports. The names must match exactly what was exported (or use as to rename):
// math.js exports: add, subtract, multiply, PI
import { add, multiply, PI } from "./math.js";
console.log(add(3, 4)); // Output: 7
console.log(multiply(3, 4)); // Output: 12
console.log(PI); // Output: 3.14159265
File path rules:
| Path | Meaning |
|---|---|
"./math.js" |
Same directory as current file |
"../utils/math.js" |
Parent directory, into utils/ |
"/src/utils/math.js" |
Absolute path from site root |
"lodash" |
npm package — resolved by bundler or Node.js |
⚠️ Always include the
.jsextension in browser ES modules. Node.js and bundlers often resolve it automatically, but browsers require the full path.
3.2 — Importing the Default Export
No curly braces. You choose any name you want:
// greeting.js: export default function greet(name) {...}
import greet from "./greeting.js";
import sayHello from "./greeting.js"; // You can name it anything
console.log(greet("Alice")); // Output: Hello, Alice!
console.log(sayHello("Bob")); // Output: Hello, Bob!
Both greet and sayHello are the same exported function — the name is entirely the importer’s choice.
3.3 — Importing Default and Named Together
// api.js: default export is request(), named exports are BASE_URL and TIMEOUT
import request, { BASE_URL, TIMEOUT } from "./api.js";
// Default comes first, named exports follow in braces after the comma
3.4 — Renaming at Import (as)
Use as to give an import a different local name:
import { add as mathAdd, multiply as mathMultiply } from "./math.js";
import { add as arrayMerge } from "./array-utils.js";
console.log(mathAdd(2, 3)); // Output: 5
console.log(arrayMerge([1], [2])); // Output: [1, 2]
3.5 — Side-Effect-Only Import
Import a module purely to run its setup code — no exports needed:
import "./polyfills.js"; // Adds browser compatibility features
import "./analytics.js"; // Starts analytics tracking
import "./styles.css"; // Load stylesheet (with a bundler)
3.6 — Import All as a Namespace (* as)
Import every export from a module into one object:
import * as MathUtils from "./math.js";
console.log(MathUtils.add(3, 4)); // Output: 7
console.log(MathUtils.PI); // Output: 3.14159265
This is covered in depth in Chapter 4.
3.7 — Imports Are Live Bindings — Not Copies
This is one of the most important and surprising properties of ES modules:
// counter.js
export let count = 0;
export function increment() { count++; }
// main.js
import { count, increment } from "./counter.js";
console.log(count); // Output: 0
increment();
console.log(count); // Output: 1 ← count updated! It is NOT a copy.
increment();
console.log(count); // Output: 2
The imported count is a live reference to the variable in counter.js — not a snapshot taken at import time. When the module updates count, the importing file sees the new value immediately.
⚠️ You cannot reassign an imported binding from outside the module:
import { count } from "./counter.js"; count = 5; // ❌ TypeError: Assignment to constant variableOnly the module that owns the variable can change it. You must call a function the module provides (like
increment()) to trigger changes.
3.8 — Top-Level Await in Modules
Inside a module (and only a module), you can use await at the top level — outside any async function:
// config.js
const response = await fetch("https://api.myapp.com/config");
const config = await response.json();
export const { apiUrl, theme, locale } = config;
// Any module importing from config.js automatically waits
// for this await to complete before proceeding
// main.js
import { apiUrl } from "./config.js";
// By the time this line runs, config.js has already finished fetching
console.log(apiUrl); // Output: https://api.myapp.com
⚠️ Top-level await blocks the importing module. Don’t put slow awaits in widely-shared modules.
CHAPTER 4 — NAMESPACES
What Is a Namespace?
A namespace is a named container that groups related items under a single identifier. In JavaScript modules, namespace imports (import * as Name) collect all of a module’s exports into one object, accessed with dot notation.
Real-world analogy — a toolbox:
Instead of carrying 20 individual tools in your pockets, you carry one toolbox. You access each tool by opening the box: toolbox.hammer, toolbox.wrench. The toolbox is the namespace — it organises everything under one handle.
4.1 — Creating a Namespace Import
// validators.js
export function isEmail(value) { return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value); }
export function isPhone(value) { return /^[+\d][\d\s\-]{6,14}$/.test(value); }
export function isPostcode(value) { return /^[A-Z]{1,2}\d[A-Z\d]? ?\d[A-Z]{2}$/i.test(value); }
export function isNonEmpty(value) { return typeof value === "string" && value.trim().length > 0; }
export function isInRange(value, min, max) {
return typeof value === "number" && value >= min && value <= max;
}
// main.js
import * as Validators from "./validators.js";
console.log(Validators.isEmail("alice@example.com")); // Output: true
console.log(Validators.isPhone("+44 7700 900000")); // Output: true
console.log(Validators.isNonEmpty(" ")); // Output: false
console.log(Validators.isInRange(7, 1, 10)); // Output: true
All five exports are now properties of the Validators object.
4.2 — Why Use Namespaces?
1 — Avoiding name collisions:
// Without namespace — names clash:
import { format } from "./date-utils.js";
import { format } from "./number-utils.js"; // ❌ SyntaxError: duplicate local name
// With namespaces — no collision:
import * as DateUtils from "./date-utils.js";
import * as NumberUtils from "./number-utils.js";
DateUtils.format(new Date()); // ✅ Date formatting
NumberUtils.format(1234567); // ✅ Number formatting
2 — Documenting the source — always clear where a function comes from:
// Without namespace — where does validate() come from?
import { validate, sanitise, normalise } from "./string-utils.js";
// With namespace — source is always visible:
import * as StringUtils from "./string-utils.js";
StringUtils.validate("hello");
StringUtils.sanitise("<script>alert('xss')</script>");
3 — Passing a whole module’s API as an argument:
import * as MathUtils from "./math.js";
function applyAll(operations, a, b) {
return Object.entries(operations)
.filter(([name, val]) => typeof val === "function")
.map(([name, fn]) => `${name}(${a}, ${b}) = ${fn(a, b)}`);
}
console.log(applyAll(MathUtils, 10, 3));
// Output:
// add(10, 3) = 13
// subtract(10, 3) = 7
// multiply(10, 3) = 30
// divide(10, 3) = 3.333...
4.3 — Namespace Objects Are Sealed (Read-Only)
The namespace object created by import * as X is sealed — you cannot add, remove, or replace properties:
import * as Utils from "./math.js";
Utils.add = function() {}; // ❌ TypeError: Cannot assign to read only property
Utils.newFn = () => {}; // ❌ TypeError: Cannot add property
delete Utils.PI; // ❌ TypeError: Cannot delete property
This is intentional — it prevents accidental corruption of a module’s public API.
4.4 — Namespace Patterns in Professional Code
Feature layer with one namespace per domain:
import * as CartAPI from "../api/cart.js";
import * as ProductAPI from "../api/product.js";
import * as UserAPI from "../api/user.js";
async function checkout(userId, cartId) {
const user = await UserAPI.getById(userId);
const cart = await CartAPI.getById(cartId);
const items = await Promise.all(
cart.itemIds.map(id => ProductAPI.getById(id))
);
return CartAPI.processCheckout(user, cart, items);
}
Namespace facade over a barrel:
// auth/index.js
export { login, logout, refreshToken } from "./tokens.js";
export { getCurrentUser, updateProfile } from "./user.js";
export { hasPermission, requireRole } from "./permissions.js";
// consumer.js
import * as Auth from "./auth/index.js";
if (!Auth.hasPermission("admin")) Auth.requireRole("editor");
const user = Auth.getCurrentUser();
4.5 — Namespace vs Named Imports — Decision Guide
| Situation | Prefer |
|---|---|
| Using 1–3 specific items | Named imports { a, b, c } |
| Using most/all of a module’s exports | Namespace * as X |
| Two modules export the same name | Namespace (avoids collision) |
| Repeatedly calling from the same module | Namespace (source always visible) |
| Bundle size / tree shaking is critical | Named imports (unused exports removable) |
💡 Tree shaking and namespaces: Bundlers like Webpack and Rollup remove unused code (“tree shaking”). With
import { add }, they know onlyaddis used and can remove everything else. Withimport * as Math, they must include all exports. For production apps, prefer named imports where bundle size matters.
CHAPTER 5 — DYNAMIC IMPORTS
What Is a Dynamic Import?
Everything so far — import { x } from "./file.js" — is static import: always at the top of the file, resolved before any code runs, always loaded unconditionally.
Dynamic import — import("./file.js") — loads a module on demand at runtime, exactly when needed. It returns a Promise that resolves to the module’s namespace object.
Real-world analogy — a library: Static imports are like arriving at work with every book you might ever need. Dynamic imports are like having a library next door — you only go get a book when you actually need it.
5.1 — The Dynamic Import Syntax
// Static (always loads at startup):
import { greet } from "./greeting.js";
// Dynamic (loads on demand, returns a Promise):
const module = await import("./greeting.js");
module.greet("Alice");
import() is a function call, not a statement. This means:
- It can appear anywhere in your code (inside functions, conditions, loops)
- It returns a Promise that must be
await-ed or.then()-chained - The path can be a computed string — not just a literal
5.2 — Accessing Exports from a Dynamic Import
The resolved value is the module’s namespace object — same as import * as X:
// math.js exports: add, multiply, PI, and a default Calculator class
const mathModule = await import("./math.js");
console.log(mathModule.add(3, 4)); // Output: 7 (named export)
console.log(mathModule.PI); // Output: 3.14159265 (named export)
console.log(mathModule.default); // Output: [class Calculator] (default export)
Accessing the default export cleanly:
// Destructure with rename:
const { default: Calculator } = await import("./math.js");
const calc = new Calculator();
5.3 — Conditional Loading
Load a module only when a specific condition is met:
async function initialise(userRole) {
if (userRole === "admin") {
// Only admins load the admin panel — saves bandwidth for everyone else:
const { AdminPanel } = await import("./admin/AdminPanel.js");
AdminPanel.initialise();
} else {
const { UserDashboard } = await import("./dashboard/UserDashboard.js");
UserDashboard.initialise();
}
}
Without dynamic imports, both modules would be downloaded for every user.
5.4 — Event-Triggered Loading (Code Splitting)
Load expensive modules only when the user triggers an action:
const reportBtn = document.getElementById("generate-report");
reportBtn.addEventListener("click", async function() {
reportBtn.disabled = true;
reportBtn.textContent = "Loading...";
try {
// pdf-generator.js is large — only download it on demand:
const { generatePDF } = await import("./pdf-generator.js");
await generatePDF(getReportData());
reportBtn.textContent = "Download Ready";
} catch (err) {
console.error("PDF generation failed:", err);
reportBtn.textContent = "Failed — Try Again";
reportBtn.disabled = false;
}
});
Users who never click the button never download the PDF library.
5.5 — Dynamic Paths
The path in import() can be a computed string:
// Load a language file based on user's locale:
async function loadTranslations(locale) {
const supported = ["en", "fr", "de", "es", "yo"];
const safe = supported.includes(locale) ? locale : "en";
const { default: translations } = await import(`./locales/${safe}.js`);
return translations;
}
const t = await loadTranslations("fr");
console.log(t.greeting); // Output: Bonjour!
⚠️ Dynamic paths and bundlers: When you use a variable in a path, bundlers (Webpack, Vite) must include all files that could possibly match the pattern. Use a clear, predictable pattern (
./locales/${locale}.js) so the bundler knows which files to include. Never use fully user-controlled paths — see the security note at the end of this chapter.
5.6 — Loading Multiple Modules in Parallel
async function loadDashboard() {
const [
{ default: Chart },
{ DataTable },
{ KPIWidget }
] = await Promise.all([
import("./charts/Chart.js"),
import("./tables/DataTable.js"),
import("./widgets/KPIWidget.js")
]);
// All three downloaded in parallel:
new Chart("#revenue-chart", { type: "bar" });
new DataTable("#sales-table");
new KPIWidget("#kpi-panel");
}
5.7 — Lazy Loading in Frameworks
Every major framework uses dynamic imports for performance:
React — lazy loading a component:
import { lazy, Suspense } from "react";
// AdminPage.js is NOT in the initial bundle:
const AdminPage = lazy(() => import("./AdminPage.js"));
function App() {
return (
<Suspense fallback={<div>Loading...</div>}>
<AdminPage /> {/* Downloads only when this component renders */}
</Suspense>
);
}
Vue — async component:
const AdminPanel = defineAsyncComponent(() => import("./AdminPanel.vue"));
React Router — route-level code splitting:
const routes = [
{ path: "/", component: lazy(() => import("./pages/Home.js")) },
{ path: "/shop", component: lazy(() => import("./pages/Shop.js")) },
{ path: "/admin", component: lazy(() => import("./pages/Admin.js")) },
];
// Each page's code only downloads when the user navigates to it
5.8 — Preloading for Anticipated Interactions
For modules you’ll need soon but not immediately:
// User is browsing products — they'll probably click "Add to Cart"
// Start downloading the cart module in the background:
setTimeout(() => {
import("./cart/CartModal.js"); // Preload silently — no await needed
}, 3000);
// When the user clicks, the module is likely already cached:
addToCartBtn.addEventListener("click", async () => {
const { CartModal } = await import("./cart/CartModal.js");
CartModal.open(); // Near-instant — already loaded
});
5.9 — Static vs Dynamic Imports — Decision Guide
| Use Case | Static import |
Dynamic import() |
|---|---|---|
| Core dependencies always needed | ✅ | ❌ |
| Optional or rarely-used features | ❌ | ✅ |
| Conditional loading (if/switch) | ❌ | ✅ |
| Dynamic paths (variable in path) | ❌ | ✅ |
| Must appear at top of file | ✅ (required) | ✅ (works anywhere) |
| Route-based code splitting | ❌ | ✅ |
| Works with tree shaking | ✅ Best | ✅ Also works |
⚠️ Never use user-controlled input directly in a dynamic import path:
// ❌ Dangerous — user could supply "../../../sensitive-file" const module = await import(userInput); // ✅ Safe — validate against a whitelist: const allowed = ["chart", "table", "map"]; if (!allowed.includes(componentName)) throw new Error("Invalid component"); const module = await import(`./components/${componentName}.js`);
PHASE 2 — APPLIED EXERCISES
Exercise 1 — Named and Default Exports
Objective: Build a string-utils.js module and consume it in two different ways.
Scenario: A content management system needs string manipulation tools across multiple files.
Warm-up mini-example:
// tiny-utils.js
export const shout = str => str.toUpperCase() + "!";
export const whisper = str => str.toLowerCase() + "...";
export default str => str.trim();
// consumer.js
import trim, { shout, whisper } from "./tiny-utils.js";
console.log(shout("hello")); // Output: HELLO!
console.log(whisper("HELLO")); // Output: hello...
console.log(trim(" hi ")); // Output: hi
Step-by-step instructions:
- Create
string-utils.jswith these named exports:capitalise(str)— first letter uppercase, rest lowercasetitleCase(str)— capitalise every wordtruncate(str, maxLength, suffix = "...")— cut atmaxLength, append suffixcountWords(str)— number of words (split on whitespace)reverseWords(str)— reverse word order: “hello world” → “world hello”slugify(str)— lowercase, spaces to hyphens, remove non-alphanumeric: “Hello World!” → “hello-world”
- Add a default export:
sanitise(str)— trims whitespace and strips HTML tags (replace<[^>]*>with"") - Create
consumer-named.js— import onlytruncate,titleCase, andslugify. Test with sample strings and log the results. - Create
consumer-namespace.js— import the whole module as* as StringUtils. Access the default export viaStringUtils.default.
Self-check questions:
- What is the advantage of the export list at the bottom vs inline
exportkeywords on each declaration? - Can
consumer-named.jsusecapitaliseif it wasn’t imported? Try it and note the error message. - In
consumer-namespace.js, how do you access the default export through the namespace object?
Exercise 2 — Barrel File and Re-Exports
Objective: Organise a utility library so consumers only ever import from one path.
Scenario: A data processing library split across four modules:
math.js—sum,average,median,clamparray.js—unique,flatten,groupBy,chunkdate.js—formatDate,daysBetween,isWeekendvalidate.js—isEmail,isUrl,isNonEmpty
Step-by-step instructions:
- Create each of the four files with working stub implementations.
- Create
utils/index.jsas a barrel that re-exports everything from all four. - Create
app.jsthat imports everything it needs from"./utils/index.js"only — never from the individual files. - Test that
app.jscan usesum,unique,formatDate, andisEmailthrough the single import.
Self-check questions:
- If you add a new function to
math.js, which is the only file you need to update for it to be accessible via the barrel? - What happens if
array.jsandmath.jsboth export a function namedsum? How would you resolve the conflict inside the barrel?
Exercise 3 — Namespace Imports
Objective: Refactor messy individual imports into clean, collision-free namespaces.
Before (names clash, hard to track origins):
import { get, post, put, del } from "./http.js";
import { get as storageGet, set, remove } from "./storage.js";
import { log, warn, error } from "./logger.js";
import { format as formatDate, parse, isValid } from "./date.js";
import { format as formatNum, round, floor } from "./number.js";
Step-by-step instructions:
- Create stub implementations of all five modules.
- Refactor all five imports using
* as:import * as Http from "./http.js"; import * as Storage from "./storage.js"; import * as Logger from "./logger.js"; import * as DateFmt from "./date.js"; import * as NumFmt from "./number.js"; - Update all call sites (e.g.,
get(url)→Http.get(url)). - Write a
processDataPipeline(url)function that uses all five namespaces in sequence: fetch data, validate dates, format numbers, log results, cache to storage.
Self-check questions:
- Before refactoring, why did
getandformatneedasaliases? - After refactoring with namespaces, are aliases still necessary?
- Try adding a new property to one of the namespace objects. What error do you get?
Exercise 4 — Dynamic Import for Code Splitting
Objective: Build a dashboard that loads feature panels only when the user requests them.
Scenario: A three-panel dashboard — Analytics, Settings, Help. Each panel is a separate module that should only download when its tab is clicked.
Warm-up mini-example:
document.getElementById("btn").addEventListener("click", async () => {
const { render } = await import("./heavy-module.js");
render(document.getElementById("container"));
});
Step-by-step instructions:
- Create
analytics.js,settings.js,help.js. Each exports adefaultfunctionrender(container)that writes panel content into the element. - In
main.js, add click listeners to three tab buttons. - On click:
- Show a loading indicator
- Dynamically import the correct module
- Call its
render()function - Handle import errors gracefully
- Add a module cache so a panel loaded once is not re-imported on subsequent clicks:
const moduleCache = new Map();
async function loadPanel(name, container) {
container.innerHTML = "<p>Loading...</p>";
if (!moduleCache.has(name)) {
const mod = await import(`./${name}.js`);
moduleCache.set(name, mod);
}
moduleCache.get(name).default(container);
}
Self-check questions:
- What type does
import()return? How do you access a default export from the result? - The browser’s module map already caches modules — why does the application-level
Mapstill add value? - What happens if the user clicks the same tab button multiple times in rapid succession before the first load finishes? How would you prevent a double-load?
Exercise 5 — Full Module Architecture
Objective: Design and implement a complete modular application for a student grade tracker.
Required structure:
grades-app/
models/
Student.js → default: Student class
Grade.js → default: Grade class
services/
gradeService.js → named: calculateAverage, getLetterGrade, getTopStudents
reportService.js → named: generateReport, exportCSV
utils/
math.js → named: average, round, clamp
format.js → named: formatPercent, formatName
index.js → barrel: re-exports all classes and service functions
main.js → entry point: imports everything from barrel, runs the app
Step-by-step instructions:
- Build all files with working implementations.
Studentclass:id,name,gradesarray,addGrade(subject, score),getAverage().gradeServiceusesmath.jsutilities.getLetterGradereturns A/B/C/D/F.reportService:generateReport(students)returns a formatted string;exportCSV(students)returns CSV text.index.jsbarrel re-exports all classes and service functions.main.jscreates 5 students, adds grades, prints a report — importing everything from"./index.js"only.
PHASE 3 — PROJECT SIMULATION
Project: Modular Blog Platform
Scenario: You are architecting the JavaScript codebase for a lightweight blogging platform. The system must be modular and optimised — with code splitting so the editor and admin tools only load for users who need them.
This project uses all five chapters: module structure (Ch.1), named/default/barrel exports (Ch.2), named/default/namespace/live-binding imports (Ch.3), namespace patterns for service layers (Ch.4), dynamic imports for features and locale loading (Ch.5).
Stage 1 — Core Data Models
// models/Post.js
export default class Post {
static #nextId = 1;
constructor(title, content, authorId, tags = []) {
this.id = Post.#nextId++;
this.title = title;
this.content = content;
this.authorId = authorId;
this.tags = tags;
this.createdAt = new Date();
this.updatedAt = new Date();
this.published = false;
this.slug = Post.#slugify(title);
}
static #slugify(title) {
return title.toLowerCase()
.replace(/[^a-z0-9\s-]/g, "")
.trim()
.replace(/\s+/g, "-");
}
publish() {
this.published = true;
this.updatedAt = new Date();
return this;
}
update(fields) {
Object.assign(this, fields);
this.updatedAt = new Date();
if (fields.title) this.slug = Post.#slugify(fields.title);
return this;
}
get summary() {
const words = this.content.split(/\s+/);
return words.slice(0, 20).join(" ") + (words.length > 20 ? "..." : "");
}
toJSON() {
return {
id: this.id, title: this.title, slug: this.slug,
summary: this.summary, authorId: this.authorId, tags: this.tags,
published: this.published,
createdAt: this.createdAt.toISOString(),
updatedAt: this.updatedAt.toISOString()
};
}
}
// models/Author.js
export default class Author {
static #nextId = 100;
constructor(name, email, bio = "") {
this.id = Author.#nextId++;
this.name = name;
this.email = email;
this.bio = bio;
this.posts = [];
}
get displayName() { return this.name; }
}
Stage 2 — Utility Layer (Named Exports + Barrel)
// utils/string.js
export const slugify = str =>
str.toLowerCase().replace(/[^a-z0-9\s-]/g, "").trim().replace(/\s+/g, "-");
export const truncate = (str, max, suffix = "...") =>
str.length <= max ? str : str.slice(0, max - suffix.length) + suffix;
export const capitalise = str =>
str.charAt(0).toUpperCase() + str.slice(1).toLowerCase();
export const wordCount = str =>
str.trim().split(/\s+/).filter(Boolean).length;
export const readingTime = str => {
const mins = Math.ceil(wordCount(str) / 200); // Average reading: ~200 wpm
return mins + " min read";
};
// utils/date.js
export const formatDate = (date, locale = "en-GB") =>
new Date(date).toLocaleDateString(locale, {
year: "numeric", month: "long", day: "numeric"
});
export const timeAgo = date => {
const secs = Math.floor((Date.now() - new Date(date)) / 1000);
if (secs < 60) return "just now";
if (secs < 3600) return Math.floor(secs / 60) + " minutes ago";
if (secs < 86400) return Math.floor(secs / 3600) + " hours ago";
return Math.floor(secs / 86400) + " days ago";
};
// utils/validate.js
export const isEmail = v => /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(v);
export const isNonEmpty = v => typeof v === "string" && v.trim().length > 0;
export const minLength = (v, n) => typeof v === "string" && v.trim().length >= n;
// utils/index.js — barrel
export * from "./string.js";
export * from "./date.js";
export * from "./validate.js";
Stage 3 — Service Layer (Namespace Imports)
// services/PostService.js
import Post from "../models/Post.js";
import * as StringUtils from "../utils/string.js";
import * as Validate from "../utils/validate.js";
const _posts = new Map(); // In-memory store
export function createPost(title, content, authorId, tags = []) {
if (!Validate.isNonEmpty(title)) throw new Error("Title is required.");
if (!Validate.minLength(content, 50)) throw new Error("Content must be at least 50 characters.");
const post = new Post(title, content, authorId, tags);
_posts.set(post.id, post);
return post;
}
export function publishPost(postId) {
const post = _posts.get(postId);
if (!post) throw new Error("Post not found: " + postId);
return post.publish();
}
export function getPublished() {
return Array.from(_posts.values())
.filter(p => p.published)
.sort((a, b) => b.createdAt - a.createdAt);
}
export function getByTag(tag) {
return getPublished().filter(p => p.tags.includes(tag));
}
export function search(query) {
const q = query.toLowerCase();
return getPublished().filter(p =>
p.title.toLowerCase().includes(q) ||
p.content.toLowerCase().includes(q)
);
}
export function getStats() {
const all = Array.from(_posts.values());
const published = all.filter(p => p.published);
const totalWords = published.reduce((sum, p) => sum + StringUtils.wordCount(p.content), 0);
return {
total: all.length,
published: published.length,
drafts: all.length - published.length,
totalWords,
avgWords: published.length ? Math.round(totalWords / published.length) : 0
};
}
// services/index.js — service barrel (exports namespaces, not individual functions)
export * as PostService from "./PostService.js";
export * as AuthorService from "./AuthorService.js";
Stage 4 — Dynamic Features (Dynamic Imports + Caching)
// features/editor.js — only loaded for logged-in authors
import * as PostService from "../services/PostService.js";
import * as StringUtils from "../utils/string.js";
import * as Validate from "../utils/validate.js";
export function initEditor(container) {
container.innerHTML = `
<div class="editor">
<h2>Write New Post</h2>
<input id="post-title" placeholder="Title..." />
<textarea id="post-content" placeholder="Write your post (min 50 chars)..." rows="10"></textarea>
<input id="post-tags" placeholder="Tags, comma-separated..." />
<button id="save-draft">Save Draft</button>
<button id="publish-btn">Save & Publish</button>
<p id="editor-status"></p>
</div>
`;
const status = container.querySelector("#editor-status");
function readFormData() {
return {
title: container.querySelector("#post-title").value,
content: container.querySelector("#post-content").value,
tags: container.querySelector("#post-tags").value
.split(",").map(t => t.trim()).filter(Boolean)
};
}
container.querySelector("#save-draft").addEventListener("click", () => {
const { title, content, tags } = readFormData();
try {
const post = PostService.createPost(title, content, 1, tags);
status.textContent =
`Draft saved: "${post.title}" · ${StringUtils.readingTime(post.content)}`;
status.style.color = "green";
} catch (err) {
status.textContent = "Error: " + err.message;
status.style.color = "red";
}
});
container.querySelector("#publish-btn").addEventListener("click", () => {
const { title, content, tags } = readFormData();
try {
const post = PostService.createPost(title, content, 1, tags);
PostService.publishPost(post.id);
status.textContent = `Published: "${post.title}"`;
status.style.color = "green";
} catch (err) {
status.textContent = "Error: " + err.message;
status.style.color = "red";
}
});
}
// features/admin.js — only loaded for admin users
import * as PostService from "../services/PostService.js";
import * as DateUtils from "../utils/date.js";
export function initAdmin(container) {
const stats = PostService.getStats();
const posts = PostService.getPublished();
container.innerHTML = `
<div class="admin-panel">
<h2>Admin Dashboard</h2>
<section class="stats">
<p>Total posts: <strong>${stats.total}</strong></p>
<p>Published: <strong>${stats.published}</strong></p>
<p>Drafts: <strong>${stats.drafts}</strong></p>
<p>Avg length: <strong>${stats.avgWords} words</strong></p>
</section>
<h3>Published Posts</h3>
<ul>
${posts.map(p =>
`<li>${p.title} — <em>${DateUtils.timeAgo(p.createdAt)}</em></li>`
).join("")}
</ul>
</div>
`;
}
Stage 5 — Main Entry Point
// main.js — static imports for always-needed code
import Post from "./models/Post.js";
import Author from "./models/Author.js";
import * as PostService from "./services/PostService.js";
import * as Utils from "./utils/index.js";
// --- Seed data ---
const alice = new Author("Alice Chen", "alice@blog.com");
const bob = new Author("Bob Okafor", "bob@blog.com");
const post1 = PostService.createPost(
"Getting Started with JavaScript Modules",
"JavaScript modules are one of the most important features added to the language in recent " +
"years. They allow developers to split their code into isolated, reusable pieces that are easy " +
"to test and maintain. In this post we will explore exports, imports, namespaces, and dynamic " +
"loading with practical examples from a real codebase.",
alice.id,
["javascript", "modules", "tutorial"]
);
const post2 = PostService.createPost(
"Why Async Await Changed Everything",
"Before async and await arrived in ES2017 writing asynchronous JavaScript meant navigating " +
"callback hell or wrestling with Promise chains. Async await made it possible to write async " +
"code that reads just like synchronous code dramatically improving readability and reducing " +
"the likelihood of subtle timing bugs in complex asynchronous workflows.",
bob.id,
["javascript", "async", "tutorial"]
);
PostService.publishPost(post1.id);
PostService.publishPost(post2.id);
// --- Render public blog ---
function renderBlog() {
const posts = PostService.getPublished();
const app = document.getElementById("app");
app.innerHTML = `
<header><h1>The Dev Blog</h1></header>
<main>
${posts.map(p => `
<article class="post-card">
<h2>${p.title}</h2>
<p class="meta">
${Utils.formatDate(p.createdAt)} · ${Utils.readingTime(p.content)}
</p>
<p class="summary">${p.summary}</p>
<p class="tags">${p.tags.map(t => `<span>#${t}</span>`).join(" ")}</p>
</article>
`).join("")}
</main>
<footer>
<button id="editor-btn">✏️ Write Post</button>
<button id="admin-btn">⚙️ Admin</button>
<div id="feature-panel"></div>
</footer>
`;
// --- Dynamic feature loading with application-level cache ---
const moduleCache = new Map();
async function loadFeature(name) {
const panel = document.getElementById("feature-panel");
panel.innerHTML = "<p>Loading...</p>";
try {
if (!moduleCache.has(name)) {
const mod = await import(`./features/${name}.js`);
moduleCache.set(name, mod);
}
const mod = moduleCache.get(name);
if (name === "editor") mod.initEditor(panel);
if (name === "admin") mod.initAdmin(panel);
} catch (err) {
panel.innerHTML = `<p style="color:red">⚠️ Failed to load ${name}: ${err.message}</p>`;
}
}
document.getElementById("editor-btn")
.addEventListener("click", () => loadFeature("editor"));
document.getElementById("admin-btn")
.addEventListener("click", () => loadFeature("admin"));
}
renderBlog();
Stage 6 — Advanced Challenge: Dynamic Locale Loading
// locales/en.js
export default { readMore: "Read more", byAuthor: "By", loading: "Loading..." };
// locales/fr.js
export default { readMore: "Lire la suite", byAuthor: "Par", loading: "Chargement..." };
// locales/yo.js (Yoruba)
export default { readMore: "Ka siwaju", byAuthor: "Nipa", loading: "Nduro..." };
// i18n.js — locale module with dynamic import + caching
const SUPPORTED = ["en", "fr", "de", "es", "yo"];
const _cache = new Map();
let _active = null;
export async function loadLocale(locale) {
const safe = SUPPORTED.includes(locale) ? locale : "en";
if (_cache.has(safe)) { _active = _cache.get(safe); return _active; }
const { default: messages } = await import(`./locales/${safe}.js`);
_cache.set(safe, messages);
_active = messages;
return messages;
}
export const t = key => _active?.[key] ?? key;
export const getSupportedLocales = () => [...SUPPORTED];
// In main.js — detect browser language, load the right locale before rendering:
const browserLocale = navigator.language.split("-")[0];
await loadLocale(browserLocale); // Top-level await — waits before renderBlog()
renderBlog();
Reflection Questions:
PostService.jsusesimport * as StringUtilsandimport * as Validateas namespaces. What are the trade-offs vs named imports for a production application with strict bundle-size requirements?- The
moduleCacheMap inmain.jsstores loaded feature modules. The browser’s module map also caches them. Why does the application-level cache still add value on top of what the browser already does? utils/index.jsusesexport * from "./string.js". What risk doesexport *introduce when two sub-modules export the same name? How would you resolve it in the barrel?- In the locale loader,
await import(./locales/${safe}.js)uses a template literal path. What must a bundler (Vite, Webpack) do at build time when it encounters a pattern like this? What happens to bundle size? services/index.jsre-exports usingexport * as PostService from "./PostService.js". A consumer then writesimport * as Services from "./services/index.js"and callsServices.PostService.createPost(...). Trace the full chain — what isServices, what isServices.PostService, and what isServices.PostService.createPost?
QUIZ & COMPLETION CHECKLIST
Self-Assessment Quiz
Q1: What three things do ES modules provide that plain <script> tags do not?
Q2: What is the difference between a named export and a default export? Can a module have both?
Q3: What is wrong with each of these lines?
export 42;
export default function() {}
export default class {}
Q4: When importing a named export, what are the two requirements about the name you use?
Q5: Explain “live bindings” in one sentence, then demonstrate with a two-file code example.
Q6: You need format from both ./date.js and ./number.js. Write two ways to import both without conflict.
Q7: What does import * as Utils from "./utils.js" produce? Can you add a new property to it?
Q8: What is a barrel file and why is it useful?
Q9: Write the code to dynamically load ./chart.js only when a button is clicked, access its default export as Chart, and handle load failures.
Q10: What is the difference between import Chart from "./chart.js" and const { default: Chart } = await import("./chart.js")?
Answer Key
A1: Any three of: own private scope (no global pollution), strict mode always on, import/export syntax, deferred execution, single evaluation (module map cache), live bindings, top-level await.
A2: Named exports are identified by their name — a module can have many. A default export is the “main” export — exactly one per module. Yes, a module can have both simultaneously.
A3: (1) export 42 — you cannot export a bare value; bind it first: export const ANSWER = 42. (2 & 3) — two export default statements — only one default is allowed per module.
A4: (1) The name must exactly match what was exported — or you must use as to rename it. (2) It must be wrapped in curly braces { }.
A5: A live binding means the import is a real-time reference to the exported variable — if the exporting module changes it, the importing file sees the updated value immediately. Example: export let count = 0 and increment(). Calling increment() makes count read as 1 in the importing file.
A6:
// Option 1 — rename with 'as':
import { format as formatDate } from "./date.js";
import { format as formatNum } from "./number.js";
// Option 2 — namespaces:
import * as DateFmt from "./date.js";
import * as NumFmt from "./number.js";
// Use: DateFmt.format(), NumFmt.format()
A7: A namespace object — a sealed object where each property is one of the module’s exports. No — namespace objects are frozen; attempting to add, remove, or replace properties throws a TypeError.
A8: A barrel file (index file) is a module that re-exports items from multiple sub-modules through a single path. It simplifies imports for consumers — one import path instead of many — and lets you reorganise internal files without updating every consumer.
A9:
document.getElementById("chart-btn").addEventListener("click", async () => {
try {
const { default: Chart } = await import("./chart.js");
new Chart(document.getElementById("canvas")).render();
} catch (err) {
console.error("Failed to load chart:", err.message);
}
});
A10: The first is a static import — runs at startup, Chart is available throughout the file, must be at the top level. The second is a dynamic import — returns a Promise, runs only when that line executes, works anywhere in code. Both give you the same Chart value.
Completion Checklist
| # | Requirement | ✓ |
|---|---|---|
| 1 | Can explain what a module is and the problem it solves | ✓ |
| 2 | Understand private scope, strict mode, and single-evaluation | ✓ |
| 3 | Can use <script type="module"> correctly in the browser |
✓ |
| 4 | Can write named exports inline and as a bottom list | ✓ |
| 5 | Can write a default export and choose when to use it | ✓ |
| 6 | Can rename exports using as |
✓ |
| 7 | Can build a barrel file using re-exports | ✓ |
| 8 | Can import named exports with { } and default without { } |
✓ |
| 9 | Can import both default and named in one statement | ✓ |
| 10 | Can rename imports using as |
✓ |
| 11 | Understand live bindings and why imported names cannot be reassigned | ✓ |
| 12 | Can use top-level await inside a module |
✓ |
| 13 | Can create namespace imports with import * as X |
✓ |
| 14 | Understand when to prefer namespace vs named imports (tree shaking) | ✓ |
| 15 | Can use import() for dynamic on-demand loading |
✓ |
| 16 | Can access both default and named exports from a dynamic import | ✓ |
| 17 | Can implement conditional loading and an application-level module cache | ✓ |
| 18 | Know when to use static vs dynamic imports | ✓ |
| 19 | Built the full Blog Platform project combining all five chapters | ✓ |
Key Gotchas Summary
| Mistake | Why It Happens | Fix |
|---|---|---|
export 42 — bare value |
Forgetting to bind it to a name | export const ANSWER = 42 |
Two export default in one file |
Only one default allowed per module | Remove one; convert the other to a named export |
| Named import without curly braces | Confusing named and default syntax | Named → { name }, Default → name (no braces) |
Missing .js extension in browser |
Browsers need the full path | Always include .js for browser ES modules |
import inside a function or if |
Static imports must be at top level | Use dynamic import() for conditional loading |
| Reassigning an imported binding | Imports are live read-only references | Call a function the module exports to change the value |
Forgetting await before import() |
import() returns a Promise |
const mod = await import("./file.js") |
Accessing .default on a static default import |
Static default import is already the value | Only namespace objects have a .default property |
| Dynamic path from user input | Path traversal security risk | Always validate against a whitelist before using in import() |
| Circular imports (A imports B, B imports A) | Modules can’t fully initialise each other | Refactor shared code into a third module C |
One-Sentence Summary
JavaScript modules transform a tangled global codebase into a network of self-contained, explicitly connected files — each declaring what it provides through exports and what it needs through imports — while dynamic imports allow expensive code to load on demand, keeping initial page loads fast.
Tutorial generated by AI_TUTORIAL_GENERATOR · Source curriculum: W3Schools JavaScript Modules (5 pages)