JavaScript Fundamentals: ES Modules: import and export
If you’ve been writing JavaScript in a single file, you’ll eventually want to split your code across multiple files. That’s where ES Modules come in. They let you export functions, objects, or values from one file and import them into another.
What Are ES Modules?
ES Modules (ESM) are the standard way to organize JavaScript code into reusable pieces. Before ES Modules, developers used patterns like CommonJS or AMD. Now, modern JavaScript has a built-in module system that works in browsers and Node.js.
The key keywords are export and import. You export something from a file to make it available elsewhere, then import it where you need it.
Named Exports
The most common pattern is named exports. You export specific items from a module, then import exactly what you need.
// math.js - the module
export const add = (a, b) => a + b;
export const subtract = (a, b) => a - b;
export const multiply = (a, b) => a * b;
// main.js - importing
import { add, multiply } from './math.js';
console.log(add(2, 3)); // 5
console.log(multiply(4, 5)); // 20
You can export as many items as you need. When importing, you choose which ones to bring in.
Renaming Imports
Sometimes a name conflicts with something already in your file, or you just want a clearer name. You can rename on import:
import { add as sum, multiply as times } from './math.js';
console.log(sum(1, 2)); // 3
console.log(times(3, 4)); // 12
This is useful when working with multiple modules that export the same name.
Default Exports
A module can also have one default export. This is common when a module represents a single thing:
// logger.js
export default function log(message) {
console.log('[LOG]', message);
}
// You can import default with any name
import myLogger from './logger.js';
myLogger('Hello world'); // [LOG] Hello world
Default and named exports can coexist in the same file:
// helpers.js
export default function helper() { /* ... */ }
export const version = '1.0.0';
// Import default and named separately
import myHelper, { version } from './helpers.js';
Namespace Import
If you want to import everything a module exports as a single object, use namespace import:
import * as Math from './math.js';
console.log(Math.add(1, 2)); // 3
console.log(Math.subtract(5, 3)); // 2
console.log(Math.multiply(3, 3)); // 9
This gives you an object containing all exports. It’s less common but useful when you want to keep things organized.
Re-exporting
You can re-export items from other modules without importing them first:
// index.js
export { add, subtract } from './math.js';
export {logger.js';
This is common in index files that serve as a public API for a package.
Using Modules in default } from ’./ the Browser
To use ES Modules in a browser, add type="module" to your script tag:
<script type="module">
import { add } from './math.js';
console.log(add(10, 20)); // 30
</script>
Modules loaded this way follow CORS rules, meaning the server must serve appropriate headers.
Common Gotchas
A few things to watch out for:
File extensions matter. In Node.js, you often need to include the extension:
// Node.js prefers
import { add } from './math.js';
Modules are scoped. Variables in a module aren’t global—they’re private unless exported.
Top-level await works in ES Modules. You can use await at the top level without wrapping in an async function:
const data = await fetch('/data.json').then(r => r.json());
Module Resolution
When you import from ’./math.js’, JavaScript needs to find that file. This is called module resolution. Node.js and browsers handle this differently.
In Node.js, you can sometimes omit the extension:
// These work in Node.js
import { add } from './math';
import { add } from './math.js';
import { add } from './math/index.js';
In browsers, the full path matters more:
import { add } from './math.js';
import { add } from '../shared/utils.js';
Node.js also supports JSON imports:
import config from './config.json' with { type: 'json' };
Static vs Dynamic Imports
All the imports we’ve seen are static—they’re at the top of the file and run before anything else. JavaScript also supports dynamic imports that load modules on demand:
// Load heavy module only when needed
button.addEventListener('click', async () => {
const { heavyFunction } = await import('./heavy.js');
heavyFunction();
});
This is useful for code splitting and lazy loading. The module isn’t downloaded until the user clicks the button.
When to Use Each Pattern
Use named exports for most things—they’re explicit and make it clear what’s available from a module.
Use default exports when a module’s main purpose is one thing, like a single utility function or class.
Mix both when needed, but don’t overdo it. A clean module exports what it needs, nothing more.
Summary
ES Modules give you a clean way to split code across files:
- Use named exports for multiple items:
export const foo = ... - Use default exports for single primary exports:
export default ... - Import with destructuring:
import { foo } from './module.js' - Rename as needed:
import { foo as bar } from './module.js' - Use dynamic imports for code splitting:
await import('./module.js')
Once you start using modules, you’ll find it much easier to organize code and share functionality between files.