Engineering Notes
Home/Languages/JavaScript/Modules (ES Modules)
Languages · JavaScript

Modules (ES Modules)

Before ES modules, all scripts shared one global scope — every <script> tag's top-level variables collided in the same namespace. Modules give each file its own scope, with explicit, opt-in sharing via import/export.

💻 Languages● Intermediate⏱ 6 min read🛠️ 2 exercises🎤 5 interview Q&As

🎯 What you'll learn

  • Why Modules Exist
  • Enabling ES Modules
  • Named Exports
  • Default Exports
  • Namespace Import

1Why Modules Exist

Before ES modules, all scripts shared one global scope — every <script> tag's top-level variables collided in the same namespace. Modules give each file its own scope, with explicit, opt-in sharing via import/export.

2Enabling ES Modules

ContextHow to enable
Browser<script type="module" src="app.js"></script>
Node.jsfile extension .mjs, OR "type": "module" in package.json for .js files

3Named Exports

js
// math.js
export const PI = 3.14159;
export function square(x) { return x * x; }
export class Vector { /* ... */ }

// or export a group at the bottom:
const E = 2.71828;
function cube(x) { return x ** 3; }
export { E, cube };
js
// app.js
import { PI, square, cube as cubeFn } from "./math.js"; // rename on import with `as`

Named exports can be imported selectively, and renamed on either side.

4Default Exports

js
// user.js
export default class User { /* ... */ }
js
// app.js
import User from "./user.js"; // no curly braces, and the local name can be ANYTHING
import AnyNameAtAll from "./user.js"; // still works — default import isn't tied to the export's name

A module can have at most one default export, but any number of named exports — often combined.

5Namespace Import

js
import * as math from "./math.js";
math.square(4); // access everything as properties of the namespace object

6Re-exporting (Barrel Files)

js
// index.js — collects and re-exports from multiple files
export { square, cube } from "./math.js";
export { default as User } from "./user.js";
export * from "./constants.js";

Common for creating a single clean entry point (import { square, User } from "./index.js") instead of many deep import paths.

7Module Scope and Hoisting Behavior

js
console.log(later); // ReferenceError — imports are live bindings, but hoisted like let/const
import { later } from "./somewhere.js";

import statements are hoisted to the top of the module (they must run before any other code can use them), but the actual binding follows the same TDZ-like rules as let/const for anything computed at module-evaluation time.

8Live Bindings, Not Copies

js
// counter.js
export let count = 0;
export function increment() { count++; }
js
// app.js
import { count, increment } from "./counter.js";
console.log(count); // 0
increment();
console.log(count); // 1 — the imported binding reflects changes in the source module

Unlike CommonJS's module.exports (which exports a snapshot object), ES module imports are live references to the exporting module's bindings.

app.js's imported count is an indirect link into counter.js's real binding slot, not a copy
app.js's imported count is an indirect link into counter.js's real binding slot, not a copy

9CommonJS vs ES Modules (Quick Contrast)

CommonJS (require)ES Modules (import)
Syntaxconst x = require("./x")import x from "./x.js"
Loadingsynchronous, dynamic (any code path)static (top-level only, analyzed before execution) — plus dynamic import()
Exportsmodule.exports = {...} (snapshot)live bindings
File extension needed in import pathusually optionalusually required (./x.js, not ./x)
Default in Node.js files (unless "type": "module").mjs, or "type": "module"

(Full comparison covered in the Node.js CommonJS vs ESM topic.)

Under the HoodLinking Before Execution, and What "Live Binding" Really Means▾

ES modules are loaded in two distinct, separate phases — this two-phase design is precisely why static import/export behaves so differently from a CommonJS require() call.

Phase 1 — Instantiation (linking), no code runs yet

The engine parses every module in the dependency graph, builds a module record for each one listing its imports and exports, and connects — "links" — each import to the exact binding slot it refers to in the source module. Critically, no module body code executes during this phase — it's pure static analysis of the import/export graph.

text
app.js imports { count } from counter.js
   ↓ linking phase resolves this to: app.js's `count` binding IS counter.js's `count` slot,
     not a copy of whatever value it currently holds (it may not even be initialized yet)

This static, execute-nothing-yet phase is why import paths can't be computed dynamically at the top level (import { x } from someVariable is a syntax error) — the linker needs to know every dependency before anything runs, which is also what enables tools to catch a typo'd import name before your program ever executes a single line.

Phase 2 — Evaluation, exactly once per module, in dependency order

Only after linking completes does the engine actually run each module's top-level code, exactly once, visiting dependencies before dependents (a module is evaluated before anything that imports from it, following the dependency graph — cycles are handled by evaluating in the order first encountered and leaving not-yet-reached bindings in their TDZ-like unset state, which is the deeper mechanism behind the "partially initialized module" pitfall).

Live bindings are literally indirect references, not copied values

js
// counter.js
export let count = 0;
export function increment() { count++; }

When app.js does import { count } from "./counter.js", the linking phase does not copy 0 into a new local variable called count. It creates an indirect binding — app.js's count is wired directly to counter.js's actual count slot (the same kind of binding-slot mechanism the Scope topic's Context objects use internally). Reading count in app.js always re-reads the live slot in counter.js; there is no intermediate copy anywhere to go stale. This is a fundamentally different mechanism from CommonJS's module.exports = {...}, which really does hand out a reference to one specific object — mutating a property of that object is visible to importers, but reassigning module.exports itself afterward is not retroactively seen by anyone who already imported it.

⚠️ Common Pitfalls

  1. Forgetting the file extension in relative import paths for native ESM (./math fails; ./math.js is required in browsers and Node ESM, unlike bundler-based setups which often tolerate omitting it).
  2. Mixing default and named import syntax incorrectly — import { default as X } works but is unusual; prefer import X from "..." for default exports.
  3. Assuming imports are copies — mutating an imported live binding's source elsewhere updates every importer; this differs from CommonJS's snapshot behavior and can surprise developers moving between the two systems.
  4. Circular imports — two modules importing from each other can result in one side seeing an incomplete (partially initialized) module during the first evaluation pass; restructure to avoid true circular dependencies where possible.

📋 Quick Reference

text
export const x = 1;           export function f(){}      export default class {}
import { x, f } from "./m.js";  import Default from "./m.js";  import * as ns from "./m.js";
export { x as renamed };        import { x as local };

🎤 Interview Q&A

Questions interviewers ask about this topic. Answer out loud first, then open a card to compare.

Q1How do ES modules work?core▾

Files with import/export, statically analyzable, loaded asynchronously, each with its own scope and strict mode, singletons (evaluated once and cached), with live bindings to exports.

Follow-ups interviewers ask
  • Named vs default exports? Named exports are explicit and refactor-friendly; default exports allow any import name.
Q2What is tree shaking?core▾

Bundlers remove unused exports thanks to ESM's static structure. Side-effectful modules and CommonJS limit it; "sideEffects": false in package.json helps.

Follow-ups interviewers ask
  • Why prefer named exports for libraries? Better tree shaking and auto-imports.
Q3What are dynamic imports?core▾

import('./module.js') returns a promise, loading code on demand: code splitting, conditional loading, or loading ESM from CommonJS.

Follow-ups interviewers ask
  • React usage? React.lazy(() => import('./Page')).
Q4How do circular dependencies behave?senior▾

Modules can import each other; one may see uninitialized bindings (TDZ errors) during evaluation. Restructure to break cycles or defer access to runtime.

Follow-ups interviewers ask
  • How to detect? madge or bundler warnings.
Q5What's top-level await?core▾

In ES modules, await can be used at the top level, delaying evaluation of the module and its importers until it resolves; useful for config loading but can slow startup.

Follow-ups interviewers ask
  • Available in CommonJS? No.

🛠️ Practice exercises

Each exercise is a file in this topic folder. Code exercises are self-checking: run them and make the checks pass.