ES modules: import and export
Split code into files with export and import, use named and default exports, re-export, and understand module scope and live bindings.
What you will learn
- Export values from a file and import them into another
- Choose between named and default exports
- Rename imports and import a whole module as a namespace
- Explain module scope, strict mode, live bindings and why a module runs once
On this page
Until now each program has been one file. Real programs are split into modules: files that share chosen pieces with each other. A module hides what it does not export, so each file can be understood on its own. JavaScript’s own module system is called ES modules (ESM). It is standard, so browsers and Node.js use the same import and export syntax.
Every example in this course so far has been a module. Node.js treats a file as an ES module when:
- its name ends in
.mjs, or - its name ends in
.jsand the nearestpackage.jsoncontains"type": "module".
The next lesson covers package.json. Until then, .mjs files are the simplest choice.
There is also a third case you may stumble on. Since Node.js 22.7, a .js file with no "type" setting anywhere is run as CommonJS (the older system, covered two lessons on) first, and if that fails because the file contains import or export, Node runs it again as an ES module. This is called syntax detection. It means a hello.js that starts with import can work even though you never said it was a module. If a package.json exists but has no "type", Node also prints a warning each time. A .cjs file, or a package.json with "type": "commonjs", gets no second chance, and import there is a syntax error.
Do not rely on syntax detection. Name the file .mjs, or set "type": "module", so the file behaves the same on every Node.js version and in every tool that reads it.
Named exports
Put export in front of a declaration to make it available to other files.
export const TAX_RATE = 0.2;
export function add(a, b) { return a + b;}
export function withTax(price) { return price * (1 + TAX_RATE);}Another file imports the names it wants, in braces, from the file’s path:
import { add, TAX_RATE } from "./math.mjs";The path must start with ./ or ../ for your own files. In Node.js you must also write the file extension: "./math.mjs", not "./math". Names without a path, such as "node:fs", refer to built-in modules or installed packages.
Default exports
A module can also have one default export: the main thing it provides.
export const defaultName = "world";
export default function greet(name = defaultName) { return `Hello, ${name}!`;}Import a default without braces, and choose any name for it: import greet from "./greeter.mjs". Named exports must be imported by their real names, or renamed with as. A file can have both kinds, as this one does.
Named exports are usually the better default choice. Their names are checked: importing a name that does not exist is an error before any code runs. A default import can be given any local name, so the same function may be called three different things in three files.
Using the modules
Four more files take part, and main.mjs imports all of them. First the helpers:
export let count = 0;
export function increment() { count += 1;}console.log("loaded_once.mjs is running");
export const startedAt = "boot";import { startedAt } from "./loaded_once.mjs";
export const message = `started at ${startedAt}`;export { add, withTax } from "./math.mjs";export { default as greet } from "./greeter.mjs";Now main.mjs, in three parts. Each part sits next to the idea it shows. The first part has the imports and the first checks.
import greet, { defaultName } from "./greeter.mjs";import { add, TAX_RATE as rate } from "./math.mjs";import * as math from "./math.mjs";import { count, increment } from "./counter.mjs";import { add as addAgain, greet as hello } from "./index.mjs";import "./loaded_once.mjs";import { message } from "./other_user.mjs";
console.log(greet(), greet("Ada"), defaultName);console.log(add(2, 3), rate);console.log(math.withTax(100));console.log(math);console.log(addAgain === add, hello === greet);Run node main.mjs with just this part:
loaded_once.mjs is runningHello, world! Hello, Ada! world5 0.2120[Module: null prototype] { TAX_RATE: 0.2, add: [Function: add], withTax: [Function: withTax]}true trueGo through it in order.
- Imports run first.
loaded_once.mjs is runningis the first line of output, before anything inmain.mjs. Node loads and runs all imported modules before the importing module’s own code. - A module runs once.
main.mjsandother_user.mjsboth importloaded_once.mjs, and its message printed once. Node keeps the module after the first load and gives everyone the same one. import greet, { defaultName } from "./greeter.mjs"combines a default and a named import.TAX_RATE as raterenames an import.greet as helloandadd as addAgaindo the same for the re-exports.import * as mathgathers all named exports into one namespace object. Node shows it as[Module: null prototype]. Call its functions asmath.withTax(100).import "./loaded_once.mjs"imports only for the side effect of running the file.- Re-exports.
index.mjsusesexport { add, withTax } from "./math.mjs"to forward names, andexport { default as greet }to give a default export a name. A file like this works as the front door of a folder.addAgain === addistruebecause both names refer to one function.
Live bindings
An imported name is not a copy. It is a read-only view of the variable in the exporting module. When that module changes the variable, importers see the new value.
Add the next part to the end of main.mjs:
console.log(count);increment();increment();console.log(count);
try { count = 10;} catch (error) { console.log(error.name);}Run it again. The new lines at the end of the output are:
02TypeErrorcount was 0 before the two increment() calls and 2 after. The importer did not do anything: the value changed inside counter.mjs, and the count it imported followed. Assigning to an import from the outside, as in count = 10, throws a TypeError. Only the module that owns a variable can change it, which is why counter.mjs exports an increment function.
Module scope and strict mode
Add the last part of main.mjs:
console.log(message);
const notGlobal = 1;console.log(globalThis.notGlobal, typeof this, typeof require);The last lines of output are:
started at bootundefined undefined undefinedmessage comes from other_user.mjs, which imported startedAt from the module that ran once. The second line shows what module scope means:
- Top-level names belong to the module. A
constor function at the top of a module is not a global.globalThis.notGlobalisundefinedin the output even thoughnotGlobalexists inmain.mjs. Two modules can use the same names without clashing. - Modules are always strict. The rules from the strict mode lesson apply without writing
"use strict". thisat the top level isundefined, andrequiredoes not exist. (Old CommonJS files had both. A later lesson covers them.)- Imports and exports must appear at the top level of a file, not inside functions or
ifblocks. The next-but-one lesson showsimport(), which can load a module at any point. - Importing a file that imports back, directly or through others, is a circular import. It can work, but a name may not be initialised yet when it is read. Avoid circles by moving shared code into a third file.
Common pitfalls
- Leaving out the file extension.
import "./math"fails in Node.js. Write./math.mjs. - Leaving out
./.import "math.mjs"looks for a package calledmath.mjs, not your file. - Using braces with a default import, or leaving them off a named one.
import { greet }does not find the default export. - Importing a name that is not exported. This is an error when the module loads, before any of your code runs. Check the spelling.
- Assigning to an imported name. It throws. Export a function that changes the value instead.
- Expecting
importto run when the line is reached. All static imports run first. - Putting
exporton something inside a function. Exports must be at the top level.
Exercises
This script computes sales totals per region and prints them. Split it into data.mjs (the data and the totals function), format.mjs (the formatting function) and main.mjs (the printing loop), keeping the output the same.
const sales = [ { region: "north", amount: 120 }, { region: "south", amount: 80 }, { region: "north", amount: 50 },];
function totalByRegion(rows) { const totals = {}; for (const { region, amount } of rows) { totals[region] = (totals[region] ?? 0) + amount; } return totals;}
function formatLine(region, total) { return `${region.padEnd(8)}${String(total).padStart(5)}`;}
for (const [region, total] of Object.entries(totalByRegion(sales))) { console.log(formatLine(region, total));}north 170south 80Show solution
The data and the totals function go together, since the function only works on that data shape. Export the names the other files need.
export const sales = [ { region: "north", amount: 120 }, { region: "south", amount: 80 }, { region: "north", amount: 50 },];
export function totalByRegion(rows) { const totals = {}; for (const { region, amount } of rows) { totals[region] = (totals[region] ?? 0) + amount; } return totals;}The formatting function has no dependencies.
export function formatLine(region, total) { return `${region.padEnd(8)}${String(total).padStart(5)}`;}main.mjs imports from both and does the printing.
import { sales, totalByRegion } from "./data.mjs";import { formatLine } from "./format.mjs";
for (const [region, total] of Object.entries(totalByRegion(sales))) { console.log(formatLine(region, total));}Run node main.mjs:
north 170south 80The output is the same as the single file. totalByRegion and formatLine can now be tested or reused independently.
Save these two files in one folder. Before you run anything, predict what live_b.mjs prints.
export let n = 1;
export function bump() { n++;}import { n, bump } from "./live_a.mjs";
bump();bump();console.log(n);
try { n++;} catch (error) { console.log(error.name);}Expected result: two lines. Work out what they are, then run node live_b.mjs to check.
Show solution
3TypeErrorThe import is a live view of n in live_a.mjs, which bump increased twice, so the first line is 3. Writing n++ in live_b.mjs throws a TypeError, because an importer cannot assign to an imported binding.
Summary
- A file is a module. Use
exportfor what others may use andimportto get it. Node.js needs the full path with extension. - Named exports keep their names. A file may also have one default export, which importers name themselves.
asrenames,import * asgathers a namespace, andexport ... fromre-exports.- Imported names are live, read-only views. Imports run before the importing file, and each module runs once.
- Module scope is private, and modules are strict by default.
Next: package.json, which describes a project, and npm, which installs other people’s modules.