JavaScript Import Export
JavaScript modules let you split a program into separate files, each with its own private scope, and share specific pieces of functionality between them using export and import statements. Before ES6 (2015), JavaScript had no built-in module system — developers relied on global <script> tags, manually ordered files, or community conventions like CommonJS (Node’s require) and AMD. The ES6 module system, often called ESM, standardized this directly into the language and is now supported natively by every modern browser and by Node.js. Understanding import/export is essential for writing maintainable, reusable, and properly encapsulated code.
Overview: How Modules Work
A JavaScript file becomes a module the moment it uses import or export (or, in Node, when it has a .mjs extension or "type": "module" in package.json). Modules differ from ordinary scripts in several important ways:
- Private scope by default. Everything declared at the top level of a module — variables, functions, classes — is local to that module. Nothing leaks onto the global object, unlike classic scripts loaded via
<script>tags that all share one global scope. - Explicit exposure. The
exportkeyword marks a binding as available to other modules. Theimportkeyword pulls specific exported bindings into the current module’s scope. - Strict mode always. Modules run in strict mode automatically; you never need
"use strict". - Singletons. A module is fetched, parsed, and evaluated exactly once per program, no matter how many other modules import it. The result is cached in the engine’s module map, and every importer receives references to the same underlying module instance.
- Live bindings, not copies. When you
importa value, you get a live, read-only reference to the exporting module’s binding — not a snapshot. If the exporting module later reassigns that variable, every importer sees the new value. - Static structure. Aside from dynamic
import(), import/export declarations must appear at the top level (not insideifblocks or functions) and use string-literal specifiers. This lets tools analyze the dependency graph without running any code, which is what enables tree-shaking — bundlers can detect unused exports and drop them from the final bundle.
In the browser, you opt into modules with <script type="module" src="app.js"></script>. In Node.js, you either name files with a .mjs extension or set "type": "module" in the nearest package.json. Module files loaded this way also require relative specifiers to include their file extension (./math.js, not ./math) — a rule bundlers like webpack or Vite often relax, but the native runtimes enforce.
Syntax
There are two families of exports and several ways to import them.
| Form | Example |
|---|---|
| Named export (inline) | export const PI = 3.14; |
| Named export (list) | export { add, subtract }; |
| Renamed export | export { add as sum }; |
| Default export | export default function() { ... } |
| Named import | import { add } from './math.js'; |
| Renamed import | import { add as plus } from './math.js'; |
| Namespace import | import * as math from './math.js'; |
| Default import | import User from './user.js'; |
| Default + named together | import User, { ADMIN_ROLE } from './user.js'; |
| Re-export | export { add } from './math.js'; |
| Re-export everything | export * from './math.js'; |
| Dynamic import (an expression) | const mod = await import('./math.js'); |
A module can have any number of named exports but at most one default export. The default export is really just a named export whose name is the special identifier default, which is why you can pick any local name for it when importing.
Examples
The following examples show a small “library” module and the module that consumes it. In real projects these would live in two separate files; here the two files are shown one after another with a comment marking each file boundary, exactly as you would create them on disk.
Example 1: Named exports
// mathUtils.js
export const PI = 3.14159;
export function add(a, b) {
return a + b;
}
export function circleArea(radius) {
return PI * radius * radius;
}
// app.js
import { PI, add, circleArea } from './mathUtils.js';
console.log(add(4, 7));
console.log(circleArea(3));
console.log(PI);
Output:
11
28.27431
3.14159
mathUtils.js exposes three named bindings. app.js imports exactly the ones it needs by name, inside curly braces, and uses them as if they were declared locally. Because they are named exports, order doesn’t matter and you can import any subset.
Example 2: Default export mixed with a named export
// user.js
export default class User {
constructor(name) {
this.name = name;
}
greet() {
return `Hello, ${this.name}!`;
}
}
export const ADMIN_ROLE = 'admin';
// app.js
import User, { ADMIN_ROLE } from './user.js';
const alice = new User('Alice');
console.log(alice.greet());
console.log(ADMIN_ROLE);
Output:
Hello, Alice!
admin
user.js has one default export (the User class) and one named export (ADMIN_ROLE). Notice that the default import, User, has no braces and can be named anything on the importing side, while the named import ADMIN_ROLE must match the exported name exactly (unless you rename it with as).
Example 3: Dynamic import
async function loadPath() {
const pathModule = await import('node:path');
const joined = pathModule.join('users', 'alice', 'notes.txt');
console.log(joined);
}
loadPath();
Output:
users/alice/notes.txt
Unlike static import declarations, import() is a regular expression that returns a promise resolving to the module’s namespace object. It can be called conditionally, inside functions, and at any point in your code — making it the tool of choice for lazy-loading a module only when it’s actually needed (for example, loading a heavy chart library only after a user clicks a “Show Chart” button).
How It Works Step by Step (Under the Hood)
When the engine encounters a module, it processes it in distinct phases rather than just running top to bottom immediately:
- 1. Parsing / construction. The engine parses the file and, without executing anything, statically scans it for every
importandexportdeclaration, building a module record that lists exactly what this module needs and what it provides. - 2. Resolution. Each import specifier (like
'./mathUtils.js') is resolved to an actual module location, and that module is recursively parsed the same way, building a full dependency graph. - 3. Instantiation. The engine allocates memory for all the module’s top-level bindings before running any code. Function declarations are fully hoisted (immediately callable), while
let,const, andclassbindings exist but sit in the temporal dead zone until their declaration line executes. Crucially, imported bindings are linked directly to the exporting module’s bindings in memory — this is the mechanism behind live bindings. - 4. Evaluation. Only now does the engine actually run each module’s top-level code, in dependency order (dependencies before dependents). Each module runs exactly once; its exports are then cached, so a second
importof the same module anywhere else in the program returns the identical cached namespace object instead of re-running the file.
This staged process is also what allows circular imports to work: if module A imports from module B and B imports from A, the linking in step 3 means that as long as neither module tries to use the other’s export before evaluation reaches that point, both modules can safely reference each other’s functions and classes.
Common Mistakes
Mistake 1: Trying to reassign an imported binding. Imported names are read-only views onto the exporting module’s variable. Writing directly to an imported binding, such as count = 5 after import { count } from './state.js', throws a TypeError: Assignment to constant variable-style error at runtime, because imports can only be reassigned from within the module that declared them. If you need mutable shared state, export functions that mutate an internal variable rather than exporting the variable itself.
Mistake 2: Mixing CommonJS require with ESM import in the same file. These are two different module systems with different loading semantics. Writing import fs from 'fs'; const path = require('path'); in a file that Node treats as an ES module is a syntax/runtime error — require is not defined in ESM scope. Pick one system per file (or per project) and use dedicated interop helpers, like Node’s createRequire, only when truly necessary.
Mistake 3: Forgetting the file extension. Bundlers like webpack often let you write import { add } from './mathUtils' without an extension, but native ESM in browsers and Node requires the full path: import { add } from './mathUtils.js'. Omitting it produces a module-not-found error the moment you run it outside a bundler.
Mistake 4: Assuming imports are copies. Some developers expect that if an exporting module changes an exported let variable later, importers keep the original value, like a copied primitive. In fact, because of live bindings, importers automatically see the updated value the next time they read it — this surprises people coming from CommonJS, where module.exports values are copied references captured at require-time.
Best Practices
- Prefer named exports for utility modules with multiple related functions or constants; they force explicit, self-documenting import lists and support better auto-import tooling.
- Reserve default exports for modules whose whole purpose is exposing one primary thing, such as a single component or class.
- Avoid
export * from './file.js'in library code — it hides exactly what is being re-exported and makes it easy to accidentally leak internal names. - Always include the file extension in relative import specifiers when targeting native ESM (Node or the browser directly, without a bundler).
- Use dynamic
import()for code-splitting: defer loading large, rarely-used modules until the moment they’re actually needed. - Keep one clear responsibility per module; smaller, focused modules are easier to test, tree-shake, and reason about.
- Don’t mutate exported objects from outside the module that owns them — export functions to change internal state in a controlled way instead.
Practice Exercises
Exercise 1: Create a module stringUtils.js that has two named exports: capitalize(str) (returns the string with its first letter uppercased) and reverse(str) (returns the string reversed). Then, in a separate main.js, import both and log capitalize('hello') and reverse('hello').
Exercise 2: Take the User class from Example 2 and add a named export function createGuest() that returns new User('Guest'). Import both the default User class and the named createGuest function in one import statement.
Exercise 3: Write an async function loadFormatter(useUpper) that uses dynamic import('node:util') to fetch the built-in util module, then calls util.format('%s is %d years old', useUpper ? 'ALICE' : 'alice', 30). Predict what it logs before you check.
Summary
- ES modules give each file its own private scope; use
exportto expose bindings andimportto consume them. - A module can have many named exports but only one default export.
- Modules are evaluated exactly once and cached; every importer shares live, read-only references to the same bindings.
- Static
import/exportmust appear at the top level with literal specifiers, enabling tree-shaking; dynamicimport()is an expression usable anywhere for lazy loading. - Native ESM (browser
<script type="module">, or Node with.mjs/"type": "module") requires explicit file extensions in relative specifiers, unlike many bundlers. - Don’t mix CommonJS
requireand ESMimportin the same file, and never try to directly reassign an imported binding.
