JavaScript ES Modules: import and export
Named exports, default exports, re-exports, dynamic import, and what actually makes a file a module — the modern JavaScript module system explained.
What you'll learn
- ✓The difference between named and default exports
- ✓How to re-export from another module
- ✓When to use dynamic import()
- ✓What makes a file a module (type=module, .mjs)
- ✓How tree-shaking interacts with your export style
Prerequisites
- •Comfortable with modern JavaScript — see async/await and basic ES6 syntax
For most of JavaScript’s history, splitting code across files meant globals, IIFEs, AMD, or CommonJS. ES Modules — the version baked into the language itself since 2015 — finally unified that mess. Today they run natively in browsers, in Node, in Deno, and in every bundler. This post is a tour of the syntax and the rules behind it.
Named exports
A named export exposes a binding by name. You can have as many as you want per file:
// math.js
export const PI = 3.14159;
export function square(n) {
return n * n;
}
export class Circle {
constructor(r) { this.r = r; }
area() { return PI * square(this.r); }
}
Import them by the same name, in braces:
// app.js
import { PI, square, Circle } from './math.js';
console.log(square(4)); // 16
console.log(new Circle(2).area()); // ~12.56
You can rename on import:
import { square as sq } from './math.js';
console.log(sq(5)); // 25
Or export everything you’ve already declared, in one statement at the bottom:
// math.js
const PI = 3.14159;
function square(n) { return n * n; }
export { PI, square };
Default exports
A default export is the single “main” thing a module exports. There can be at most one per file:
// logger.js
export default function log(message) {
console.log(`[${new Date().toISOString()}] ${message}`);
}
Import without braces, and name it whatever you want at the call site:
// app.js
import log from './logger.js';
import myLogger from './logger.js'; // same thing, different local name
log('boot complete');
You can mix one default with any number of named exports:
// http.js
export default function request(url) { /* ... */ }
export function get(url) { /* ... */ }
export function post(url, body) { /* ... */ }
import request, { get, post } from './http.js';
Named vs default — which to use
A common debate. The pragmatic guidance:
- Prefer named exports for libraries and shared modules. The name at the import site matches the name at the definition site, so search and refactor work cleanly.
- Use default exports when the module truly represents a single thing — a React component, a configuration object, a class.
Default exports trip up tooling. Auto-import in editors guesses a name; if two files default-export different things called Button, the editor cannot disambiguate. Named exports never have that problem.
Re-exports
To collect modules under one entry point, re-export from a barrel file:
// components/index.js
export { Button } from './Button.js';
export { Input } from './Input.js';
export { Modal } from './Modal.js';
// also re-export a default as named
export { default as Card } from './Card.js';
// or re-export everything in one go
export * from './forms.js';
Consumers import from the barrel:
import { Button, Input, Card } from './components/index.js';
Barrels are convenient but watch out: they can defeat tree-shaking if a bundler cannot prove the unused re-exports are side-effect-free. More on that below.
Try it yourself. Create utils/string.js with named exports capitalize, slugify, and truncate. Create utils/index.js that re-exports all three. From app.js, import them via the barrel. Then refactor to import directly from utils/string.js and confirm both forms work identically.
Dynamic import
import as a statement is static — the module is resolved when the file is parsed, and the bindings are hoisted. Sometimes you want a module loaded on demand: a heavy chart library only when the user opens the dashboard, a polyfill only for old browsers.
import() as an expression returns a Promise that resolves to the module’s namespace object:
// app.js
async function showChart() {
const { Chart } = await import('./chart.js');
new Chart('#root').render();
}
document.querySelector('#open-chart').addEventListener('click', showChart);
Default exports live on .default:
const mod = await import('./logger.js');
mod.default('hello'); // the default export
Bundlers like Vite, webpack, and Rollup treat import() as a code-splitting boundary — the chunk is emitted as a separate file and only fetched when called. We cover the async side of this in async/await.
What makes a file a module
A .js file is treated as a module — rather than a classic script — in one of three ways:
- In the browser, with
<script type="module" src="app.js"> - In Node, when the file ends in
.mjs, or when the nearestpackage.jsoncontains"type": "module" - In a bundler, almost always — bundlers treat input files as modules by default
The differences from a classic script are not cosmetic:
- Modules run in strict mode automatically
- Top-level variables are module-scoped, not global
thisat the top level isundefined, not the global objectimportandexportare only legal in modules- Top-level
awaitworks in modules
If you see SyntaxError: Cannot use import statement outside a module, you have a script where you wanted a module. Add "type": "module" to package.json (Node) or type="module" to the script tag (browser).
CommonJS vs ES Modules
Node also supports the older CommonJS system: require() and module.exports. The two systems mostly interoperate, but with sharp edges:
// CommonJS
const fs = require('fs');
module.exports = { hello: () => 'hi' };
// ES Modules
import fs from 'node:fs';
export const hello = () => 'hi';
Rules of thumb in modern Node:
- New code: prefer ES Modules. Set
"type": "module"inpackage.json. - You can
importa CommonJS package from an ES Module — itsmodule.exportsbecomes the default import. - You cannot use
require()inside an ES Module withoutcreateRequire. Use dynamicimport()instead.
Tree-shaking
Bundlers analyse your imports statically and drop code that no one imports. This is called tree-shaking, and it only works when the bundler can be certain a binding is unused.
Two practices that help:
// Good — bundler sees you only need `capitalize`
import { capitalize } from './utils/string.js';
// Worse — pulls the entire module namespace
import * as str from './utils/string.js';
str.capitalize('hi');
Side effects defeat tree-shaking. If string.js does anything observable at import time — calling a function, attaching globals — the bundler must include the whole file. Keep modules side-effect-free: declare functions and constants, export them, do nothing else at the top level. You can advertise this with "sideEffects": false in package.json.
Try it yourself. Set up a tiny project with Vite (npm create vite@latest). Create utils.js with five named exports. Import one of them in main.js. Run npm run build and inspect dist/assets/*.js — confirm only the function you used appears in the output.
A small worked example
A library with named exports, a default, and a barrel:
// src/format/number.js
export function currency(n, code = 'USD') {
return new Intl.NumberFormat('en-US', { style: 'currency', currency: code }).format(n);
}
export function percent(n) {
return new Intl.NumberFormat('en-US', { style: 'percent' }).format(n);
}
// src/format/date.js
export default function formatDate(d, locale = 'en-US') {
return new Intl.DateTimeFormat(locale).format(d);
}
// src/format/index.js (the barrel)
export * from './number.js';
export { default as formatDate } from './date.js';
// app.js
import { currency, percent, formatDate } from './src/format/index.js';
console.log(currency(1299)); // $1,299.00
console.log(percent(0.42)); // 42%
console.log(formatDate(new Date())); // 6/16/2026
Every binding is named at the consumer site. Tree-shaking works. New formatters can join format/ without touching app.js.
Common pitfalls
- Forgetting the
.jsextension. In native ES Modules (Node, browsers), the extension is required. Bundlers often hide this. - Circular imports. ES Modules handle cycles by giving you a binding that may be
undefinedmid-evaluation. Refactor to break the cycle. - Mixing
module.exportsandexport. Pick one per file. They do not combine. - Re-exporting everything from a barrel that has side effects. Either remove the side effects or skip the barrel for that file.
Recap
You now know:
- Named exports expose any number of bindings by name
- A file may have at most one default export
import { x } from './m.js'is static;import('./m.js')is dynamic and async- A
.mjsfile or"type": "module"inpackage.jsonmakes Node treat a file as a module - Tree-shaking depends on static, side-effect-free imports
- Barrels are convenient but can mask unused-export removal
Next steps
Modules organise behaviour. Types organise meaning. The next two posts step into TypeScript’s deepest corner — conditional and mapped types — and show how the language’s most powerful utilities are built from a few small rules.
→ Next: TypeScript Conditional Types Explained
Questions or feedback? Email codeloomdevv@gmail.com.