Every large program eventually splits across multiple files. In the old days, each file was loaded with a separate script tag, and every variable declared at the top level landed in the same shared global scope. If two files both declared const utils = {}, the second one silently overwrote the first. No error, no warning — just broken behavior that only surfaced hours later. Debugging meant grepping every file to find who clobbered your variable. The problem grew worse as teams added third-party libraries, each adding its own names to the global pool.
ES modules solve this by giving every file its own private scope. A module's top-level variables belong to that file alone, and the only way to share them is through explicit export and import statements. You decide exactly what is public and what stays hidden. The result is code that is easier to reason about, safer to refactor, and simpler to test in isolation.
flowchart LR
M["math.js<br/>export const PI = 3.14<br/>export function add(a,b)"] -->|"import { add, PI }"| A["app.js<br/>console.log(add(2,3))"]
style M fill:#e0900b,color:#fff
style A fill:#3776ab,color:#fff
Named exports
A file can export any number of named bindings. You simply write the export keyword in front of a declaration:
export const PI = 3.14159;
export function add(a, b) { return a + b; }
Another file imports the names it needs inside curly braces:
import { add, PI } from './math.js';
The names must match exactly — add on the import side must correspond to add on the export side. You can export a binding that was declared elsewhere, as long as it is in the same file scope. One common convention is to place all exports at the bottom of the file so a quick scan tells you the public API.
If you need a different local name, you can rename during import:
import { add as plus } from './math.js';
This is not a second export; it is just a local alias. You can also import everything into a single namespace object:
import * as math from './math.js';
console.log(math.add(2, 3));
The * as syntax is useful when a module exports many names and you want to keep them grouped under a common prefix.
const math = {
add: (a, b) => a + b,
PI: 3.14,
default: (a, b) => a / b
};
const { add, PI } = math;
const divide = math.default;
console.log(add(2, 3));
console.log(PI);
console.log(divide(10, 2));
Default exports
A module can also declare one — and only one — default export. This is the value you reach for when a file has a single main responsibility, such as a single React component or a main configuration object. A default export can be any expression: a function, a class, an object, or even a primitive value.
export default function divide(a, b) { return a / b; }
The import syntax drops the braces and lets you pick any local name:
import div from './math.js';
import quotient from './math.js';
Here div and quotient are the very same export, just given different local names. Because the local name is arbitrary, some teams prefer default exports for internal files and named exports for shared libraries, though this is a matter of convention rather than a language rule.
You can mix default and named exports in the same file. React libraries do this constantly: one default component and several named helpers. The import line then carries both forms:
import React, { useState } from 'react';
Here React is the default import, and useState is a named import. The comma separates them, and the braces only wrap the named items.
flowchart TD
N["Named exports<br/>export const PI<br/>export function add"] --> I1["import { PI, add }<br/>names must match"]
D["Default export<br/>export default function"] --> I2["import anyName<br/>name is free"]
style N fill:#e0900b,color:#fff
style D fill:#3776ab,color:#fff
const math = {
PI: 3.14,
add(a, b) { return a + b; }
};
const utils = {
PI: 3.14159,
calc(r) { return 2 * r * this.PI; }
};
console.log(math.add(2, 3));
console.log(utils.calc(1));
Module scope and strict mode
Every module runs in strict mode automatically, even without a 'use strict' pragma. That means assigning to an undeclared variable throws a ReferenceError instead of silently creating a global. It also means top-level this is undefined, not the global object, and duplicate parameter names in functions are forbidden.
Because each file is its own scope, you never need IIFE wrappers to protect variables. Functions declared at the top level of a module are local too, unless you explicitly export them. The file itself is the container. This is the biggest conceptual shift from old-style scripts: the global scope is no longer the default place where everything lives. You can declare helper functions and constants at the top level of a module without worrying that another file will trample them. You can still declare global variables in a module if you absolutely must, by assigning to globalThis.name, but doing so defeats the entire purpose and should be avoided.
flowchart TD S["Script file<br/>var x = 1<br/>global scope"] --> G["Global object<br/>polluted"] M["Module file<br/>const x = 1<br/>file scope"] --> L["Local to file<br/>safe"] style S fill:#b91c1c,color:#fff style M fill:#1e7f3d,color:#fff
How modules load
When a runtime sees an import statement, it fetches the target file before executing the current one. This happens because modules form a static dependency graph: every import is known at parse time, not discovered while the program runs. The runtime builds the entire graph, loads every file, and then executes them in dependency order — deepest imports first.
This static structure is what enables tree-shaking, where build tools remove exports that were never imported. Because the graph is static, circular dependencies are detected early rather than causing infinite loops at runtime. The runtime simply records the bindings and fills them in once the cycle resolves. It is also why dynamic import exists for code that genuinely cannot be known until runtime, though that is a more advanced topic.
Paths: relative and bare
Imports from your own files always start with a dot: ./math.js or ../utils/helpers.js. The extension is required in the browser and optional in some build tools, so including it is the safest habit.
Bare imports such as import React from 'react' look for a package installed by a package manager, not a file on disk. Those only work when a bundler or server is present to resolve the name to an actual path. In plain browser modules, bare specifiers throw an error, which is why tutorials often show a build step.
Re-exporting and barrels
A module can act as a middleman by re-exporting everything from another file. This is how library authors build what is called a barrel — a single entry point that gathers dozens of internal modules into one convenient import.
export { add, subtract } from './math.js';
export { format } from './strings.js';
Now consumers only need to know about one file instead of ten. The syntax looks like an import followed by an export, and that is exactly what it is. Barrels are common in large codebases because they hide internal folder structure and present a stable public API.
What does this print? Destructuring from an object works the same way named imports do.
const math = {
add: (a, b) => a + b,
default: (a, b) => a - b
};
const { add, default: subtract } = math;
console.log(add(2, 3));
console.log(subtract(5, 2));
const { add } pulls the add property from the object.
default: subtract renames the default property to subtract.
Write createMath() that returns an object with add(a,b), subtract(a,b), and a default property set to the multiply(a,b) function. This mirrors a module that mixes named exports with a default export.
function createMath() {
// return { add, subtract, default: multiply }
}
Declare three functions inside createMath.
Return an object using shorthand property names and default: multiply.
function createMath() {
function add(a, b) { return a + b; }
function subtract(a, b) { return a - b; }
function multiply(a, b) { return a * b; }
return { add, subtract, default: multiply };
}
This counter is meant to keep its total private, like a module hides its internal variables. But the current implementation stores the count as a public property. Repair it so the count is hidden in the closure and can only be read through next(), which increments and returns the new total.
function makeCounter() {
return {
count: 0,
next() { this.count += 1; return this.count; }
};
}
Move count outside the returned object so it becomes a closure variable.
Remove the count property from the returned object entirely.
function makeCounter() {
let count = 0;
return {
next() { count += 1; return count; }
};
}
Which import statement correctly brings in a named export called sum from ./math.js?
Named exports are imported inside curly braces: import { sum } from './math.js'. The other forms import a default export, import everything into a namespace, or use CommonJS syntax.
Write mergeModules(modules) that takes an array of objects like { name: 'math', exports: { add, subtract } } and returns a single object where each key is the module name and the value is its exports object. If two modules have the same name, the later one wins. This simulates building a namespace from multiple imported modules.
function mergeModules(modules) {
// return { moduleName: exports }
}
Loop through modules and assign result[mod.name] = mod.exports.
If the same name appears twice, the second assignment naturally overwrites the first.
function mergeModules(modules) {
const result = {};
for (const mod of modules) {
result[mod.name] = mod.exports;
}
return result;
}
Recap
- ES modules give each file private scope; explicit export/import is the only way to share.
- Named exports:
export const name;import { name } from './file.js'. - Default export:
export default name;import anyName from './file.js'. - Modules are always strict mode; top-level
thisisundefined. - Named imports must match the export name; default imports can be renamed freely.
- The static dependency graph enables tree-shaking and predictable load order.
- Re-exports let you build barrel files that present a single public face.
Next you will learn to build rich strings with template literals and tagged templates — a powerful formatting tool that works inside any module.