JavaScript Modules: Import and Export Explained

A clear, ground-up explanation of how JavaScript modules work — why they exist, how to use them, and why every serious codebase depends on them. No bundler knowledge required.
Table of Contents
1. The Problem Modules Solve
Picture a development team building a medium-sized web application. In the early days, all the JavaScript lives in a single file — app.js. It starts at a few hundred lines and that feels manageable. But as the project grows, it becomes something else entirely.
That one file now contains utility functions, business logic, UI event handlers, API call helpers, and configuration constants — all mixed together. Changing one function means scrolling past thousands of unrelated lines to find it. Two developers editing the file at the same time create merge conflicts constantly. A bug in the cart calculation logic causes a test failure in the user authentication code, because both live in the same global scope and share variables in ways nobody intended.
This is not a made-up scenario. It is what JavaScript development looked like for a long time, and it is exactly the problem that the module system was designed to solve.
The core issue is scope pollution and the absence of boundaries. When all your JavaScript runs in one file (or across multiple files all loaded into the same global scope), every function, every variable, and every constant is potentially accessible from everywhere. There is nothing stopping one part of the code from accidentally overwriting or depending on another part in ways that are fragile and invisible.
Modules introduce explicit boundaries. Each file has its own scope. Nothing inside a module is visible to the outside world unless the author deliberately chooses to export it. And nothing from the outside world enters a module unless it is explicitly imported. This simple principle — everything private by default, sharing only what is intentional — is what makes large codebases maintainable.
2. What Is a Module?
A module is a JavaScript file that explicitly declares what it shares with the rest of the application and what it keeps private.
Before the module system, a JavaScript file was just a script — anything it declared went straight into the global scope, where every other script could see and modify it. Modules changed this. Each module file gets its own enclosed scope. Variables, functions, and classes defined inside a module are invisible to other files unless they are exported.
Here is the simplest way to think about it:
Without modules:
script-a.js declares → goes into global scope → script-b.js can access it (always)
script-b.js declares → goes into global scope → script-a.js can access it (always)
Result: Everything can see everything. Conflicts and hidden dependencies are inevitable.
With modules:
module-a.js declares → stays private inside module-a.js
module-a.js exports → only the exported parts are available to other files
module-b.js imports → explicitly asks for what it needs from module-a.js
Result: Clear boundaries. Deliberate connections. No accidental sharing.
JavaScript's native module system — introduced in ES2015 and referred to as ES Modules or ESM — uses two keywords to make this work: export and import.
3. The Module System at a Glance
Before getting into the details, here is the complete picture of how modules relate to each other in a typical project:
┌─────────────────────────────────────────────────────────────────┐
│ JAVASCRIPT MODULE SYSTEM │
├─────────────────────────────────────────────────────────────────┤
│ │
│ math.js utils.js api.js │
│ ┌───────────┐ ┌──────────┐ ┌──────────┐ │
│ │ add() │ ──export── │formatDate│ ─export─ │fetchUser │ │
│ │ subtract()│ │capitalize│ │postData │ │
│ │ PI │ └──────────┘ └──────────┘ │
│ └───────────┘ │ │ │
│ │ │ │ │
│ └──────────┬─────────────┘ │ │
│ │ │ │
│ v v │
│ ┌──────────────────────────────────────────┐ │
│ │ app.js │ │
│ │ import { add, PI } from "./math.js" │ │
│ │ import { formatDate } from "./utils.js" │ │
│ │ import { fetchUser } from "./api.js" │ │
│ └──────────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────┘
Each file is self-contained. math.js handles math. utils.js handles formatting. api.js handles network calls. app.js pulls in exactly what it needs from each. No file knows more about another file than it has been explicitly told.
4. Named Exports — Sharing Specific Values
The most common way to export from a module is with a named export. You place the export keyword in front of any declaration you want to make available to other files.
// math.js
export const PI = 3.14159;
export function add(a, b) {
return a + b;
}
export function subtract(a, b) {
return a - b;
}
export function multiply(a, b) {
return a * b;
}
// This is NOT exported — it stays private to this file
function internalHelper() {
return "only I can use this";
}
Everything with export in front of it is available to other modules. Everything without it is completely private. internalHelper does not exist as far as any other file is concerned.
You can also collect all your exports at the bottom of the file in a single export statement, which some developers prefer for readability:
// math.js — alternative style, same result
const PI = 3.14159;
function add(a, b) {
return a + b;
}
function subtract(a, b) {
return a - b;
}
function multiply(a, b) {
return a * b;
}
function internalHelper() {
return "only I can use this";
}
// Export only what other files should see
export { PI, add, subtract, multiply };
Both approaches are valid. The inline style makes it immediately obvious which declarations are public. The grouped export style keeps all the "interface" declarations in one place at the bottom, making it easy to scan what a module exposes.
5. Named Imports — Bringing Them In
To use something from another module, you import it by name inside curly braces, along with the path to the file:
// app.js
import { PI, add, subtract } from "./math.js";
console.log(PI); // 3.14159
console.log(add(10, 5)); // 15
console.log(subtract(10, 5)); // 5
A few important points about this syntax:
The names must match exactly. If math.js exports add, you import add — not Add, not addition. The names are case-sensitive and must correspond to what was exported.
You only import what you need. Even if math.js exports four things, you can import just two of them. The rest are ignored.
The path is relative. "./math.js" means the file is in the same directory as the importing file. "../utils/math.js" would look one directory up and into a utils folder. The path must be accurate — the module system does not guess.
You can import from multiple files in the same module:
// app.js
import { add, PI } from "./math.js";
import { formatDate, capitalize } from "./utils.js";
import { fetchUser } from "./api.js";
// Now use all of them
const result = add(2, 3);
const today = formatDate(new Date());
const user = await fetchUser(1);
Each import statement is an explicit, readable declaration of where this file's dependencies come from. Anyone reading app.js can immediately see which other files it depends on, just from the top of the file.
6. Default Exports — One Main Thing per File
Named exports are ideal when a module provides several related things. But sometimes a module exists to export one primary thing — a class, a large function, a React component. For this case, JavaScript provides default exports.
A default export uses the export default syntax:
// UserCard.js
export default function UserCard(props) {
return `<div class="user-card">${props.name}</div>`;
}
// formatCurrency.js
export default function formatCurrency(amount, currency = "USD") {
return new Intl.NumberFormat("en-US", {
style: "currency",
currency,
}).format(amount);
}
// config.js
const config = {
apiBaseUrl: "https://api.example.com",
timeout: 5000,
retries: 3,
};
export default config;
Each of these files exports exactly one thing as its default. A module can only have one default export. If a file tries to declare two export default statements, it is a syntax error.
7. Default Imports — Using Them
Importing a default export looks slightly different from importing a named export. There are no curly braces, and you can choose whatever name you want for the imported value:
// app.js
import UserCard from "./UserCard.js";
import formatCurrency from "./formatCurrency.js";
import config from "./config.js";
console.log(config.apiBaseUrl); // "https://api.example.com"
console.log(formatCurrency(1999.99)); // "$1,999.99"
The absence of curly braces is what signals to JavaScript that you are importing the default export. The name you choose (UserCard, formatCurrency, config) is entirely up to you — it does not need to match anything in the source file. However, using names that reflect what the module actually provides is strongly recommended for the sake of anyone reading the code later.
8. Named vs Default Exports — Key Differences
Understanding when to use each one is one of the more common points of confusion for developers new to modules.
| Aspect | Named Export | Default Export |
|---|---|---|
| Syntax to export | export const x = ... or export { x } |
export default x |
| Syntax to import | import { x } from "..." |
import x from "..." |
| Curly braces on import | Required | Not used |
| Name must match | Yes — must match the exported name | No — you choose any name |
| How many per file | As many as needed | Exactly one |
| Best used for | Multiple related utilities | One primary export per file |
A practical way to decide which to use: if a file exists to provide a collection of related tools (utility functions, constants, helpers), use named exports. If a file exists to provide one specific thing (a class, a component, a configuration object), use a default export.
9. Combining Default and Named Exports
A single module can have both a default export and named exports at the same time. This is common in libraries and in component files that also export associated types or helper values.
// Button.js
// Default export — the main thing this file provides
export default function Button({ label, onClick }) {
return `<button onclick="\({onClick}">\){label}</button>`;
}
// Named exports — related constants and helpers
export const BUTTON_SIZES = {
small: "btn-sm",
medium: "btn-md",
large: "btn-lg",
};
export const BUTTON_VARIANTS = {
primary: "btn-primary",
secondary: "btn-secondary",
danger: "btn-danger",
};
Importing from a file like this uses both syntaxes together:
// app.js
import Button, { BUTTON_SIZES, BUTTON_VARIANTS } from "./Button.js";
const myButton = Button({
label: "Submit",
onClick: "handleSubmit()",
});
console.log(BUTTON_SIZES.large); // "btn-lg"
The default import (Button) appears before the comma. The named imports (BUTTON_SIZES, BUTTON_VARIANTS) follow in curly braces. They come from the same file and are imported in a single statement.
10. Renaming Imports and Exports
Sometimes two modules export something with the same name, or you want to give an imported value a more descriptive name in the context of the current file. Both imports and exports can be renamed using the as keyword.
Renaming on import:
// Two different modules both export a function called "format"
import { format as formatDate } from "./dateUtils.js";
import { format as formatCurrency } from "./currencyUtils.js";
// Now both are available without conflict
console.log(formatDate(new Date()));
console.log(formatCurrency(1500));
Renaming on export:
// utils.js
function helperOne() { /* ... */ }
function helperTwo() { /* ... */ }
// Export them under different names
export { helperOne as primaryHelper, helperTwo as fallbackHelper };
Importing everything from a module under a namespace:
// Import all named exports from math.js under a single object called MathUtils
import * as MathUtils from "./math.js";
console.log(MathUtils.PI); // 3.14159
console.log(MathUtils.add(4, 6)); // 10
console.log(MathUtils.subtract(9, 3)); // 6
The * as syntax is particularly useful when working with a module that exports many things, or when you want to make it explicit in your code which operations are coming from a particular module.
11. The File Dependency Diagram
As a project grows, its modules form a network of dependencies. Each file imports from some files and may itself be imported by others. Understanding this structure is important for maintaining a codebase and diagnosing problems when they arise.
Here is a realistic example of how a small project's modules might connect:
┌──────────────┐
│ app.js │ (entry point)
└──────┬───────┘
│
┌──────────────────┼──────────────────┐
│ │ │
v v v
┌────────────┐ ┌──────────────┐ ┌────────────┐
│ auth.js │ │ cart.js │ │ api.js │
└──────┬─────┘ └──────┬───────┘ └──────┬─────┘
│ │ │
v v v
┌────────────┐ ┌──────────────┐ ┌────────────┐
│ user.js │ │ product.js │ │ http.js │
└──────┬─────┘ └──────────────┘ └────────────┘
│
v
┌────────────┐
│ utils.js │ (shared utility module, imported by several others)
└────────────┘
Reading this diagram: app.js is the entry point. It imports from auth.js, cart.js, and api.js. Those modules, in turn, import from more specialized modules. utils.js sits at the bottom of the dependency tree because it is a shared utility module that does not depend on anything else in the project.
A healthy dependency graph flows in one direction — from the entry point, downward toward more specific, self-contained modules. When you see circular dependencies (module A imports from module B, and module B imports from module A), it usually signals a design problem worth addressing.
12. The Import and Export Flow
Here is the complete flow from the moment a module is created to the moment another file uses it:
┌──────────────────────────────────────────────────────────────────┐
│ MODULE IMPORT / EXPORT FLOW │
├──────────────────────────────────────────────────────────────────┤
│ │
│ STEP 1 — Author creates a module │
│ ┌─────────────────────────────┐ │
│ │ math.js │ │
│ │ │ │
│ │ const PI = 3.14159 │ ← private (not exported) │
│ │ const SECRET = "hidden" │ ← private (not exported) │
│ │ │ │
│ │ export function add() {} │ ← public (exported) │
│ │ export const PI = 3.14159 │ ← public (exported) │
│ └─────────────────────────────┘ │
│ │ │
│ │ export │
│ v │
│ STEP 2 — JavaScript engine reads and registers the exports │
│ ┌─────────────────────────────┐ │
│ │ Module Registry │ │
│ │ math.js → { add, PI } │ │
│ └─────────────────────────────┘ │
│ │ │
│ │ import │
│ v │
│ STEP 3 — Consumer file requests specific exports │
│ ┌─────────────────────────────┐ │
│ │ app.js │ │
│ │ │ │
│ │ import { add, PI } │ │
│ │ from "./math.js" │ │
│ │ │ │
│ │ add(2, 3) // works │ │
│ │ SECRET // ReferenceError│ ← cannot access private items │
│ └─────────────────────────────┘ │
│ │
└──────────────────────────────────────────────────────────────────┘
The key behavior to notice: the consuming file (app.js) can only access what math.js chose to export. SECRET is invisible — it does not appear in the module registry, so there is no way to reach it from outside.
13. Benefits of Modular Code
The advantages of writing modular JavaScript accumulate as a project grows. They are most obvious when a codebase reaches a point where multiple developers are working on it simultaneously, but they matter even in solo projects.
Isolation of concerns. Each module focuses on one area of responsibility. A dateUtils.js file handles date formatting. A validation.js file handles form validation. Neither file needs to know or care what the other is doing. When something breaks, you know exactly which file to look in.
Private by default. Anything you do not export is genuinely private. There is no way for another file to accidentally access or modify it. This makes the internal implementation of a module safe to change without worrying about breaking code elsewhere.
Explicit dependencies. The top of every file tells you exactly where its dependencies come from. You never have to guess which script is responsible for a particular function or wonder if something might have been overwritten by another file loaded before it.
Easier testing. A self-contained module with a clear set of inputs and outputs is straightforward to test in isolation. You import the module into a test file, call its exported functions with controlled inputs, and verify the outputs — without having to set up an entire application environment.
Reusability without repetition. Once a module is written, any other file in the project can import from it. A formatCurrency function written once in utils.js can be used in twenty different components without copying the code. When the implementation needs to change, you change it in one place and every consumer gets the update.
Maintainability at scale. A codebase made of small, focused, well-named modules is significantly easier to navigate than a codebase of large, sprawling files. New developers can understand what a file does from its name and its exports, without having to read thousands of lines of mixed concerns.
14. Summary
The problem modules solve is the absence of boundaries in JavaScript. Without modules, every file shares the same global scope — anything declared anywhere is visible everywhere, which leads to naming conflicts, hidden dependencies, and code that becomes increasingly difficult to reason about as it grows.
Named exports share specific, individually named values from a module. You mark them with the export keyword, and the consumer imports them by their exact name inside curly braces. A file can have as many named exports as needed.
Default exports share one primary value from a module. You mark it with export default, and the consumer imports it without curly braces, using any name they choose. A file can have at most one default export.
Importing is how a file declares its dependencies. The import statement at the top of a file makes it immediately clear which other modules it depends on and what it uses from them.
Renaming with the as keyword resolves naming conflicts and allows you to give imported values more descriptive names in the current context. The import * as syntax gathers all of a module's named exports under a single namespace object.
The benefits of modular code — isolation, privacy, explicit dependencies, testability, reusability, and maintainability — are not just conveniences. They are the foundation of how professional JavaScript applications are structured. Understanding modules is not a supplementary skill. It is the prerequisite for working effectively in any modern JavaScript environment, whether that is a browser-based React application, a Node.js backend, or anything in between.
