ES Modules (import / export)
ES Modules (ESM) are the official JavaScript module system, standardized in ES2015 and supported natively by Node, browsers, and every modern tool. They use import/export syntax, load asynchronously, and offer features CommonJS cannot — static analysis, live bindings, and top-level await. For new projects, ESM is the recommended default.
Turning ESM on
Node treats a file as ESM when any of these is true:
Trigger | Scope |
|---|---|
File ends in | That file only |
| All |
| The evaluated string |
package.json — opt the whole project into ESM
{
"name": "my-app",
"type": "module"
}Exporting
math.js
// Named exports — as many as you like
export const add = (a, b) => a + b
export const PI = 3.14159
// Or declare first, export later
const multiply = (a, b) => a * b
export { multiply }
// One default export per module
export default function subtract(a, b) {
return a - b
}Importing
app.js
import subtract, { add, PI } from './math.js' // default + named
import * as math from './math.js' // everything as a namespace
import { add as plus } from './math.js' // rename on import
console.log(add(2, 3), subtract(5, 1), math.PI)Imports are static and hoisted
import statements are not function calls — they are declarations the engine resolves before any code runs. This static structure is ESM's superpower (tools can tree-shake and analyze the dependency graph) but also its main constraint:
All
imports are hoisted to the top and evaluated first, regardless of where you write them.You cannot conditionally
importwith a static statement, or build the specifier from a runtime variable.For dynamic needs, use the async
import()function (next section).
// ILLEGAL — static import cannot be conditional
// if (dev) import './tools.js'
// LEGAL — dynamic import() returns a promise
if (dev) {
const tools = await import('./tools.js')
tools.enable()
}Live bindings — a real difference from CJS
ESM exports live bindings, not copies. An importer always sees the current value of an exported variable, even after the exporting module reassigns it — something CommonJS cannot do:
counter.js
export let count = 0
export function increment() { count++ }import { count, increment } from './counter.js'
console.log(count) // 0
increment()
console.log(count) // 1 — the binding is live; no re-import neededTop-level await
Because ESM loads asynchronously, you can await at the top level of a module — no async wrapper. Ideal for one-time setup:
const res = await fetch('https://api.example.com/config')
export const config = await res.json()Interop with CommonJS
ESM can import CommonJS packages — the module's module.exports arrives as the default import. The reverse is harder: CommonJS cannot require() an ESM module (it is async); it must use dynamic import():
// ESM importing a CommonJS package — exports become the default
import express from 'express' // express is CJS; this works
// Named imports from CJS are best-effort (Node analyzes the exports)
import pkg from 'some-cjs-lib'
const { thing } = pkg // safest if named import failsThe full comparison and migration guidance is in CommonJS vs ES Modules.