Modules: import and export
Named vs default exports, re-exporting, and code-splitting with dynamic import().
Why modules
Before ES2015, JavaScript had no built-in way to split code across files with real isolation — every <script> tag on a page shared one global scope, so a variable in one file could silently collide with a same-named variable in another. ES modules (import/export) fix this: each module has its own scope, explicitly declares what it makes available to other files (export), and explicitly declares what it needs from other files (import) — nothing is global by accident.
Modules run today in every modern browser (via <script type="module">) and in Node.js (via .mjs files, or "type": "module" in package.json). They're also the format every modern bundler (Vite, webpack, esbuild) is built around.
<script type="module" src="app.js"></script>
{
"type": "module"
}
Named exports
A module can export any number of named values — functions, classes, constants — each imported by that exact name:
// mathUtils.js
export function add(a, b) {
return a + b;
}
export function multiply(a, b) {
return a * b;
}
export const PI = 3.14159;
// main.js
import { add, multiply, PI } from "./mathUtils.js";
console.log(add(2, 3)); // 5
console.log(multiply(4, 5)); // 20
console.log(PI); // 3.14159
An alternative style exports everything together at the bottom of the file instead of tagging each declaration individually — functionally identical, just a matter of preference:
function add(a, b) {
return a + b;
}
function multiply(a, b) {
return a * b;
}
export { add, multiply };
Renaming an import (or export) avoids a naming collision with something else already in scope:
import { add as sum } from "./mathUtils.js";
console.log(sum(2, 3)); // 5
Default exports
A module can also have one export default — the "main thing" that module provides, imported without curly braces and under whatever name the importer chooses:
// Logger.js
export default class Logger {
log(message) {
console.log(`[LOG] ${message}`);
}
}
// main.js
import Logger from "./Logger.js"; // no braces, and the name doesn't have to match
const logger = new Logger();
logger.log("Application started");
A module can mix a default export with named exports:
// api.js
export default function fetchData(url) {
return fetch(url).then(r => r.json());
}
export const BASE_URL = "https://api.example.com";
import fetchData, { BASE_URL } from "./api.js";
Named vs default exports
| Named export | Default export | |
|---|---|---|
| Syntax | export const x = ... |
export default x |
| How many per module | Any number | At most one |
| Import syntax | import { x } from "..." |
import x from "..." |
| Import name | Must match the exported name (unless renamed with as) |
Whatever the importer chooses to call it |
| Good for | A module providing several related utilities (mathUtils.js) |
A module whose whole purpose is one thing (Logger.js, a React component) |
Many teams favor named exports even for single-purpose modules, because the import name is then guaranteed to match across the whole codebase (a default export's freedom to rename can lead to the same thing being imported under different names in different files, which hurts searchability) — but both styles are common in real code, and understanding both is essential for reading other people's modules.
Re-exporting
A module can forward another module's exports without importing them into its own scope first — common in an index.js that gathers several submodules into one convenient entry point:
// shapes/index.js
export { Circle } from "./Circle.js";
export { Square } from "./Square.js";
export { default as Triangle } from "./Triangle.js";
// main.js — one import instead of three
import { Circle, Square, Triangle } from "./shapes/index.js";
Dynamic import()
import at the top of a file is static — resolved and loaded before any of the module's code runs, and it must appear at the top level (never inside an if or a function). import() is a function-like operator that loads a module at runtime, returning a Promise that resolves to the module's exports:
async function loadChart() {
const { renderChart } = await import("./chart.js");
renderChart();
}
button.addEventListener("click", () => {
loadChart(); // chart.js is only fetched/parsed once the button is actually clicked
});
This is the mechanism behind code splitting — a bundler can turn chart.js into its own separate file that's downloaded only when loadChart() actually runs, instead of bundling every possible module into one giant file loaded up front regardless of whether the user ever needs it. It's also useful for conditionally loading a module only in certain environments:
async function loadPolyfillIfNeeded() {
if (!window.IntersectionObserver) {
await import("./intersection-observer-polyfill.js");
}
}
Common mistakes
- Forgetting the file extension in a browser-native module import (
import { add } from "./mathUtils"instead of"./mathUtils.js") — Node and bundlers are often lenient about this, but a plain<script type="module">in the browser requires the exact, extension-included path. - Mixing up named and default import syntax —
import Logger from "./Logger.js"(no braces) for a default export versusimport { add } from "./mathUtils.js"(braces) for a named one; using the wrong form is a common source of "not a function" orundefinederrors. - Using
import()(dynamic) where a plain top-levelimportwould do — dynamic import adds real complexity (it's asynchronous, returns a Promise) that's only worth it when there's an actual reason to defer loading, like code-splitting a rarely-used feature. - Trying to write a static
importstatement conditionally inside anifblock — static imports must be at the top level of a module; use dynamicimport()when the decision to load a module is genuinely conditional.
Interview questions
Q: What's the practical difference between a named export and a default export?
A module can have any number of named exports, each imported by its exact name (optionally renamed with as) inside curly braces; it can have at most one default export, imported without braces under whatever name the importing file chooses. Named exports keep the imported name consistent across a codebase, which is why many style guides prefer them even for single-purpose modules.
Q: What does dynamic import() return, and why would you use it over a regular import statement?
It returns a Promise that resolves to the target module's exports, and — unlike a static import, which must appear at a module's top level and is resolved before any code runs — it can be called from anywhere at runtime, including conditionally inside a function. It's the mechanism behind code splitting: a bundler can defer downloading a rarely-needed module's code until the moment import() for it actually runs, rather than including it in the initial bundle every user downloads.