Lesson 136 min read

Modules & Imports

Split your code into neat, reusable files that work together like LEGO sets

Why Modules?

Imagine building a house where every single piece of furniture, wiring, and plumbing is crammed into one giant room. Chaos, right? Modules let you split your code into separate files, each with a clear purpose. One file handles math utilities, another handles user authentication, another handles the UI.

Each module has its own scope — variables inside a module don't leak into other modules unless you explicitly export them. Other files can then import exactly what they need. This keeps your code organized, avoids naming conflicts, and makes it easy to reuse code across projects.

JavaScript modules use import and export statements. In browsers, you need <script type="module">. In Node.js, you can use .mjs files or set "type": "module" in package.json.

Named Exports & Imports

// ─── math-utils.js ───
export const PI = 3.14159;
export function add(a, b) {
return a + b;
}
export function multiply(a, b) {
return a * b;
}
function secretHelper() {
// not exported — stays private to this file!
return 42;
}
// ─── main.js ───
import { add, multiply, PI } from "./math-utils.js";
console.log(add(2, 3)); // 5
console.log(multiply(4, 5)); // 20
console.log(PI); // 3.14159
// Rename on import to avoid conflicts
import { add as sum } from "./math-utils.js";
console.log(sum(10, 20)); // 30
// Import everything as a namespace
import * as MathUtils from "./math-utils.js";
console.log(MathUtils.add(1, 2)); // 3
console.log(MathUtils.PI); // 3.14159
Output
5
20
3.14159
30
3
3.14159

Default Exports

Each module can have one default export. It's the "main thing" the module provides. When you import a default export, you can name it whatever you want — you don't need curly braces.

Think of named exports like items in a store (you pick specific ones by name), and the default export like the store's signature product (there's only one, and everyone just calls it "the thing from that store").

A common convention: use default exports for a module's primary class or component, and named exports for utilities and constants.

Default Exports & Mixed Exports

// ─── Logger.js ───
export default class Logger {
constructor(prefix) {
this.prefix = prefix;
}
log(message) {
console.log(`[${this.prefix}] ${message}`);
}
error(message) {
console.error(`[${this.prefix} ERROR] ${message}`);
}
}
// Also export named items alongside the default
export const LOG_LEVELS = ["debug", "info", "warn", "error"];
// ─── app.js ───
// Default import — no braces, any name works
import Logger from "./Logger.js";
// Named import — still uses braces
import Logger, { LOG_LEVELS } from "./Logger.js";
const log = new Logger("App");
log.log("Started!"); // [App] Started!
log.error("Something broke"); // [App ERROR] Something broke
console.log(LOG_LEVELS); // ["debug", "info", "warn", "error"]
Output
[App] Started!
[App ERROR] Something broke
["debug", "info", "warn", "error"]

Dynamic Imports & Re-Exports

Dynamic imports use import() as a function (not a statement). They return a Promise and load the module on demand — perfect for code-splitting, where you only load code when the user actually needs it. This makes your app faster because you're not loading everything upfront.

Re-exports let you create an "index" file that gathers exports from multiple modules and re-exports them from one place. This is a clean pattern for organizing libraries.

Dynamic Imports & Barrel Files

// Dynamic import — loads a module at runtime (returns a Promise)
async function loadChart() {
const { Chart } = await import("./chart-library.js");
const chart = new Chart("#canvas");
chart.render();
console.log("Chart loaded and rendered!");
}
// Only load when the user clicks
document.querySelector("#show-chart")?.addEventListener("click", loadChart);
// ─── utils/index.js (barrel file) ───
// Re-export everything from one place
export { add, multiply } from "./math.js";
export { formatDate, parseDate } from "./dates.js";
export { validateEmail } from "./validation.js";
// ─── app.js ───
// Now import everything from one clean path
import { add, formatDate, validateEmail } from "./utils/index.js";
console.log(add(1, 2)); // 3
console.log(validateEmail("[email protected]")); // true
Output
Chart loaded and rendered!
3
true
Note: When you import { something } from a module, you're not copying the value — you're creating a live link to it. If the exporting module changes the value later, your import sees the update. This is different from CommonJS (require), where you get a snapshot. Most of the time this doesn't matter, but it's good to know!

Quick check

What's the difference between named and default exports?
Classes & OOPAsync Programming