Free lesson · JavaScript Basic to Advance
ES Module Syntax: import and export
The Two Sides of a Module Boundary
Every ES Module has two sides: an export side (what it makes available) and an import side (what it consumes from other modules). Both sides use specific syntax that the JavaScript runtime understands as structural — not just regular function calls or variable references.
// math.js — the exporting side
export const PI = 3.14159;
export function add(a, b) {
return a + b;
}
export function multiply(a, b) {
return a * b;
}
// app.js — the importing side
import { PI, add, multiply } from "./math.js";
console.log(add(2, 3));
console.log(PI * 4);
console.log(multiply(5, 6));
The export keyword on a declaration marks it as part of the module's public interface. The import statement pulls specific named values from another module's exports. The names match — what you export as add is what you import as add.
This Topic covers the syntax in detail: the forms of export, the forms of import, and the rules that govern how they interact. Some of the rules are surprising the first time you encounter them.
Forms of export
There are two main forms: inline exports (mark a declaration at the point of definition) and declaration exports (mark values for export at the bottom of a file).
// Inline export — most common
export const PI = 3.14159;
export function add(a, b) { return a + b; }
export class Logger {}
// Declaration export — list values to export at the end
const PI_DECL = 3.14159;
function addDecl(a, b) { return a + b; }
class LoggerDecl {}
export { PI_DECL, addDecl, LoggerDecl };
Both forms are equivalent in behavior. Inline is more common because it puts the export right next to the value's definition, making it immediately clear what's public. Declaration form is useful when you want to see all the exports listed in one place at the top or bottom of the file.
You can also rename on export, in case the name you want externally differs from the internal name:
function internalHelper() { /* ... */ }
export { internalHelper as helper };
// Externally, this is imported as 'helper'
// Internally in this file, it's still 'internalHelper'
This is less common but useful for compatibility — when an internal name doesn't match the public API name the module is supposed to provide.
Forms of import
Several forms, depending on what you want:
Named imports — bring in specific named exports:
import { add, multiply, PI } from "./math.js";
Renamed imports — bring something in under a different name (useful when names collide):
import { add as sum, multiply as product } from "./math.js";
console.log(sum(2, 3)); // calls 'add'
console.log(product(4, 5)); // calls 'multiply'
Namespace imports — bring everything in as a single object:
import * as math from "./math.js";
console.log(math.add(2, 3));
console.log(math.PI);
This is convenient when you want all the exports without listing them. The trade-off: it makes it harder for tools to know which specific things you actually use, which can hurt dead-code elimination in bundlers. For most use cases, named imports are preferred.
Default imports — bring in the module's default export (covered in Topic 11.3):
import Logger from "./logger.js"; // no curly braces — default import
Mixed imports — combine default and named imports:
import Logger, { LogLevel } from "./logger.js";
Side-effect imports — import for the side effect of running the module, without using any exports:
import "./setup.js"; // runs the module but doesn't bind anything
This is rare and usually a code smell. A module that does important work via side effects rather than exports is harder to test and reason about. But you'll occasionally see it for polyfills or framework initialization.
The Module Path
The string after from is the module specifier — it tells the runtime where to find the module. The rules for what's a valid specifier depend on the environment.
In ES Modules in browsers (and most modern Node configurations):
- Relative paths:
./math.js,../utils/format.js— relative to the current file - Absolute paths:
/lib/math.js— relative to some configured root - Bare imports:
lodash,react— resolved throughnode_modulesand the package system (covered in Topic 11.6)
Note that ES Module imports usually require the file extension (.js). This is a frequent surprise for people coming from CommonJS or bundler-configured projects where the extension is optional. Native browser ES Modules and modern Node ES Modules both require the extension.
// Browser ESM
import { add } from "./math.js"; // ✓ correct
import { add } from "./math"; // ✗ usually fails
// In bundler projects (Webpack, Vite, etc.), the extension is often optional
import { add } from "./math"; // ✓ works with bundler config
This inconsistency is annoying but knowing it exists prevents confusion. When in doubt, use the explicit extension.
Static and Top-Level
ES Module imports have two surprising properties: they're static and they must be top-level.
Static means: imports are resolved by the runtime before any of your code runs. The runtime sees an import statement, finds the target module, and loads it. The target module's path cannot be dynamic — you can't compute it at runtime.
// ✗ Not allowed — imports must be static
const moduleName = condition ? "moduleA.js" : "moduleB.js";
import { thing } from moduleName;
// ✓ For dynamic imports, use the dynamic import() function (covered later)
const mod = await import(moduleName);
Top-level means: import statements must appear at the top level of a module, not inside functions, conditionals, or blocks.
// ✓ Allowed
import { add } from "./math.js";
function example() {
// ✗ Not allowed
// import { multiply } from "./math.js";
}
This restriction is what makes static analysis possible. Tools can see exactly which modules a file depends on by reading the top-level imports. If imports were dynamic or conditional, that analysis would be much harder.
For cases where you do need dynamic loading (lazy loading, code splitting), there's a separate dynamic import() function — covered briefly in Topic 11.4.
Interactive animation: Rigid on purpose - top-level, static imports make the dependency graph knowable before any code runs.
What Gets Exported: Bindings, Not Values
A subtle but important detail. When you import something from another module, you're not importing a copy of the value. You're importing a binding — a live reference to the variable in the other module.
// counter.js
export let count = 0;
export function increment() {
count++;
}
// app.js
import { count, increment } from "./counter.js";
console.log(count); // 0
increment();
console.log(count); // 1 — the imported binding sees the updated value
In CommonJS (Topic 11.5), the equivalent would be a snapshot at the time of require — the imported count would remain 0 because it was copied. In ES Modules, the binding is live — it always reflects the current value in the source module.
This is rarely something you exploit deliberately. But it explains some behaviors that would otherwise be surprising. Imported names look like local variables but they're actually views into another module's scope.
Also: imported names are read-only from the importer's side. You can't reassign an import:
import { count } from "./counter.js";
count = 5; // ✗ TypeError — imports are read-only
This is enforcement of the export contract. If the source module wants to allow modification, it has to export a function for it. The imported names themselves can't be reassigned.
Interactive animation: The import that watched a variable change - the imported count follows the exporter, and reassigning it throws.
Strict Mode and the Module Environment
Every ES Module runs in strict mode automatically. There's no need to write "use strict" at the top — it's implicit. This means:
- No implicit global variables. Assigning to an undeclared variable throws.
thisat the top level isundefined, not the global object.- More restrictive parsing — some legacy features (like the
withstatement) are forbidden.
The module environment also doesn't have an arguments object at the top level (since the module isn't a function), and eval runs in its own scope rather than the module's scope.
These are mostly defensive features. They prevent some classes of bugs that were common in old-style script code. You won't run into them in everyday work, but they're part of what makes module code more predictable.
Common Pitfalls
Warning: The ES Module syntax traps.
Forgetting file extensions. Browser ES Modules and native Node ES Modules require the
.jsextension. Bundler projects often don't, leading to inconsistency between dev and production builds.Trying to use
importdynamically or inside functions. Staticimportonly works at the top level. For dynamic cases, useawait import("path").Reassigning imported names. Imports are read-only. To allow modification, the source module needs to export a setter function or expose state through getters.
Mixing module forms. ES Modules use
import/export. CommonJS usesrequire/module.exports. You can't mix them in the same file (mostly). Topic 11.5 covers the interop story.
Why This Matters in Interviews
Interview: Module syntax fluency is expected. Code that uses correct, modern
import/exportsignals current practice. Code that uses verbose patterns or mixes module systems signals stale style.Topic 11.7 — Interview Lens: Module System Questions — covers the deeper interview treatment, including the static-vs-dynamic distinction interviewers like to probe.
Try It
Exercise: Build a tiny three-file project locally and run it with Node (or your bundler of choice):
// math.js export const PI = 3.14159; export function area(radius) { return PI * radius * radius; } // format.js export function formatNumber(n) { return n.toFixed(2); } // app.js import { area } from "./math.js"; import { formatNumber } from "./format.js"; console.log(formatNumber(area(5)));Once it runs, try: renaming an import, using namespace import for one of the files, importing the same value with two different names. By the time you've done these variations, the import syntax is muscle memory.