ES modules: import and export

Beginner45 min

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 .js and the nearest package.json contains "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.

math.mjs
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.

greeter.mjs
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:

counter.mjs
export let count = 0;
export function increment() {
count += 1;
}
loaded_once.mjs
console.log("loaded_once.mjs is running");
export const startedAt = "boot";
other_user.mjs
import { startedAt } from "./loaded_once.mjs";
export const message = `started at ${startedAt}`;
index.mjs
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.

main.mjs
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 running
Hello, world! Hello, Ada! world
5 0.2
120
[Module: null prototype] {
TAX_RATE: 0.2,
add: [Function: add],
withTax: [Function: withTax]
}
true true

Go through it in order.

  • Imports run first. loaded_once.mjs is running is the first line of output, before anything in main.mjs. Node loads and runs all imported modules before the importing module’s own code.
  • A module runs once. main.mjs and other_user.mjs both import loaded_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 rate renames an import. greet as hello and add as addAgain do the same for the re-exports.
  • import * as math gathers all named exports into one namespace object. Node shows it as [Module: null prototype]. Call its functions as math.withTax(100).
  • import "./loaded_once.mjs" imports only for the side effect of running the file.
  • Re-exports. index.mjs uses export { add, withTax } from "./math.mjs" to forward names, and export { default as greet } to give a default export a name. A file like this works as the front door of a folder. addAgain === add is true because 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:

main.mjs (continued)
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:

0
2
TypeError

count 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:

main.mjs (continued)
console.log(message);
const notGlobal = 1;
console.log(globalThis.notGlobal, typeof this, typeof require);

The last lines of output are:

started at boot
undefined undefined undefined

message 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 const or function at the top of a module is not a global. globalThis.notGlobal is undefined in the output even though notGlobal exists in main.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".
  • this at the top level is undefined, and require does 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 if blocks. The next-but-one lesson shows import(), 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 called math.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 import to run when the line is reached. All static imports run first.
  • Putting export on something inside a function. Exports must be at the top level.

Exercises

ExerciseSplit a script into three modules

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.

report_single.mjs
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));
}
Output
north 170
south 80
Show 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.

data.mjs
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.

format.mjs
export function formatLine(region, total) {
return `${region.padEnd(8)}${String(total).padStart(5)}`;
}

main.mjs imports from both and does the printing.

main.mjs
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 170
south 80

The output is the same as the single file. totalByRegion and formatLine can now be tested or reused independently.

ExercisePredict the module

Save these two files in one folder. Before you run anything, predict what live_b.mjs prints.

live_a.mjs
export let n = 1;
export function bump() {
n++;
}
live_b.mjs
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
3
TypeError

The 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 export for what others may use and import to 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.
  • as renames, import * as gathers a namespace, and export ... from re-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.