Back to Tutorials
📦 Intermediate

JavaScript Modules

6 min read | Modules

JavaScript Modules

Modules split a program into files that explicitly state what they provide (export) and what they need (import). Before modules, every script shared one global namespace — naming collisions, invisible dependencies, and load-order bugs were daily life. ES Modules (ESM) fixed this at the language level, and they are how every modern framework, bundler, and Node.js project organizes code.

Step 1: Named Exports

// math.js — export several things by name
export const PI = 3.14159;
export function add(a, b) {
  return a + b;
}
export function multiply(a, b) {
  return a * b;
}
// main.js — import exactly what you need
import { PI, add, multiply } from "./math.js";
import { add as sum } from "./math.js";   // rename locally
import * as math from "./math.js";        // whole namespace: math.add(...)

Why names must match: named imports are checked against the module's actual exports — misspell one and you get an immediate, clear error, unlike a mistyped global that silently yields undefined. This static structure is also what lets bundlers tree-shake: unused exports are provably unused, so they are dropped from the production bundle.

Step 2: Default Exports

// user.js — one main thing per file
export default class User {
  constructor(name) {
    this.name = name;
  }
}
// main.js — importer chooses any name, no braces
import User from "./user.js";
import WhateverName from "./user.js";  // also works — by design

Why both kinds exist: a default export says "this file is one thing" — a component, a class. Named exports say "this file offers a toolbox". The freedom to rename defaults cuts both ways: nothing stops each teammate importing the same module under a different name, which is why many teams prefer named exports everywhere for consistency and better autocomplete.

Files can mix both:

// utils.js
export default function main() {}
export const helper = () => {};
export const VERSION = "1.0.0";
// main.js
import main, { helper, VERSION } from "./utils.js";

Step 3: Using Modules in the Browser and Node

// In index.html, load the entry file with:
//   script type="module" src="main.js"
// (a regular script tag with the type="module" attribute)

Why type="module" changes behavior: module scripts are deferred automatically (they run after HTML parsing), always use strict mode, get their own scope instead of polluting window, and each module is fetched and evaluated once no matter how many files import it — the results are cached and shared. That singleton behavior is a feature: two importers of the same module see the same state. Note that modules require a server (http://) — opening a file directly via file:// fails CORS checks. In Node.js, use "type": "module" in package.json (or the .mjs extension), and remember Node requires the file extension in relative imports: "./math.js", not "./math".

Step 4: Dynamic Imports

// Static imports load up front. Dynamic import() loads on demand:
const module = await import("./heavy-module.js");
module.doSomething();
// Perfect for optional or rarely used features
if (needsChart) {
  const { renderChart } = await import("./chart.js");
  renderChart(data);
}

Why it matters: import() returns a promise and can run anywhere, anytime — inside conditions, event handlers, route changes. Bundlers turn each dynamic import into a separate chunk, so first paint ships only the code the first screen needs. This is code splitting, the single biggest lever for load performance in large apps.

Step 5: Re-exporting (Barrel Files)

// models/index.js — one clean public surface
export { default as User } from "./user.js";
export { default as Post } from "./post.js";
export * from "./utils.js";
// elsewhere
import { User, Post } from "./models/index.js";

Barrels keep import paths short and hide internal file layout, so you can reorganize a folder without touching every consumer. Use them at package boundaries; a barrel for everything can hurt tree-shaking and create import cycles.

Common Beginner Errors and Fixes

  • "Cannot use import statement outside a module": the script was not loaded as a module. Fix: type="module" on the script tag, or "type": "module" in package.json.
  • Missing ./ or extension: import { add } from "math.js" (bare specifier) or "./math" (no extension in the browser/Node ESM). Fix: relative path and extension: "./math.js".
  • Braces confusion: import { User } fails when User is a default export; import User gets undefined when it is named. Fix: braces mirror the export style exactly.
  • Testing over file://: CORS errors in the console, nothing runs. Fix: any local server, e.g. npx serve.
  • Circular imports: module A imports B which imports A — one side sees a half-initialized module (undefined bindings). Fix: extract the shared piece into a third module both can import.

Practice Exercise

Build a tiny module system by hand (a real server or Vite dev server required):

  • Create temperature.js exporting a named function toFahrenheit(c) and a named constant FREEZING_C = 0.
  • Create format.js with a default export: a function taking (label, value) and returning them joined with a colon, using a template literal.
  • Create index.js (a barrel) re-exporting both modules' public API.
  • In main.js, import from the barrel only, and log the freezing point in Fahrenheit — expected output: "Freezing: 32".
  • Bonus: convert the format import to a dynamic import() that only loads when a verbose flag is true, and confirm in DevTools' Network tab that the file is fetched on demand.
  • When step 5 shows format.js loading lazily in the Network panel, you have seen code splitting with your own eyes — the same mechanism behind every framework's lazy routes.