Skip to content
elephantoo

Modules: import & export

Lesson 20 of 34 14 min read

Named and default exports, ES modules in the browser and Node, dynamic import() and top-level await.


So far every example has lived in a single file. Real projects have hundreds of files: one for date helpers, one for the shopping cart, one for talking to the API. Modules let each file keep its own variables private and share only what it chooses to export; other files import exactly what they need. This is the standard ES module (ESM) system, built into every modern browser and Node.js.

Why modules?#

Before modules, every <script> shared one global scope. Two files that both declared function format() would silently overwrite each other, and you had to load scripts in exactly the right order. Modules fix this:

  • Each module has its own scope. Top-level variables are not globals.
  • Dependencies are explicit. You can see at the top of a file what it uses and where it comes from.
  • Modules are always in strict mode, and each runs only once, no matter how many files import it.

Named exports#

Put export in front of any declaration you want to share:

JavaScript
// math.js
export const PI = 3.14159;

export function circleArea(r) {
  return PI * r ** 2;
}

export function clamp(value, min, max) {
  return Math.min(Math.max(value, min), max);
}

const secret = "not exported"; // private to this file

Import them by name, inside braces:

JavaScript
// main.js
import { circleArea, clamp } from "./math.js";

console.log(circleArea(2).toFixed(2)); // 12.57
console.log(clamp(150, 0, 100));       // 100

The names must match the exports exactly. You can rename on either side with as, or import everything as a namespace object:

JavaScript
// main.js
import { clamp as limit } from "./math.js";
import * as math from "./math.js";

console.log(limit(-5, 0, 10)); // 0
console.log(math.PI);          // 3.14159
console.log(Object.keys(math)); // [ 'PI', 'circleArea', 'clamp' ]

You can also export a list at the bottom of a file, which some people find easier to scan:

JavaScript
// strings.js
function capitalize(s) {
  return s.charAt(0).toUpperCase() + s.slice(1);
}
function slugify(s) {
  return s.trim().toLowerCase().replaceAll(/[^a-z0-9]+/g, "-");
}

export { capitalize, slugify as toSlug };

Default exports#

A module can have one default export — handy when a file is mainly about one thing, like a class or a component:

JavaScript
// Cart.js
export default class Cart {
  #items = [];
  add(name, price) {
    this.#items.push({ name, price });
    return this;
  }
  get total() {
    return this.#items.reduce((sum, item) => sum + item.price, 0);
  }
}

export const CURRENCY = "₹";

Import the default without braces, under any name you like; named exports still go in braces:

JavaScript
// main.js
import Cart, { CURRENCY } from "./Cart.js";

const cart = new Cart().add("Notebook", 120).add("Pen", 30);
console.log(`${CURRENCY}${cart.total}`); // ₹150

Named or default? Many teams prefer named exports: names stay consistent across the codebase, editors auto-import them reliably, and typos are caught (import { clmap } is an error, while a misnamed default import silently works). Default exports are common in React components and config files.

Running modules in Node.js#

Node supports two module systems. To use ES modules, either:

  • give files the .mjs extension, or
  • add "type": "module" to your package.json so .js files are ES modules:
JSON
{
  "name": "my-app",
  "type": "module"
}

Then run the entry file as usual:

Terminal
node main.js

Two Node rules that trip people up:

  1. Relative imports need the file extension: "./math.js", not "./math". (Bundlers like Vite let you omit it, but Node doesn't.)
  2. Package imports use the bare name: import express from "express"; — Node looks in node_modules. Built-in modules use the node: prefix: import { readFile } from "node:fs/promises";.

CommonJS: the older Node format

You'll still see this in older Node code and many tutorials:

JavaScript
// CommonJS (.cjs, or .js without "type": "module")
const fs = require("node:fs");
module.exports = { add: (a, b) => a + b };

New code should use import/export. Modern Node can import CommonJS packages, so you can use both kinds of library from an ES module.

Modules in the browser#

Add type="module" to the script tag:

HTML
<script type="module" src="./js/main.js"></script>

Module scripts behave differently from classic scripts:

  • They are deferred automatically: they run after the HTML is parsed, so the DOM is ready.
  • Top-level variables are not added to window.
  • They're loaded with CORS rules, so opening the page with file:// fails. Serve the folder with a local server, e.g. npx serve . or the VS Code Live Server extension. Build tools like Vite do this for you.
  • Browser imports need a full path including the extension ("./utils.js"), unless a bundler or an import map resolves bare names.

Re-exporting: "barrel" files#

A folder can expose a single entry point that gathers exports from several files. Say math.js, strings.js and Cart.js live in a utils/ folder:

JavaScript
// utils/index.js
export { clamp, circleArea } from "./math.js";
export * from "./strings.js";
export { default as Cart } from "./Cart.js";
JavaScript
// main.js
import { clamp, capitalize, Cart } from "./utils/index.js";
console.log(capitalize("elephantoo"), clamp(7, 0, 5), new Cart().total); // Elephantoo 5 0

Barrels are convenient, but huge ones can slow down tooling. Use them in moderation.

Imports are live, read-only bindings#

An import is not a copy; it's a live view of the exported variable. The importing module can't reassign it:

JavaScript
// counter.js
export let count = 0;
export function increment() {
  count++;
}
JavaScript
// main.js
import { count, increment } from "./counter.js";

console.log(count); // 0
increment();
console.log(count); // 1 – sees the updated value
// count = 10;      // TypeError: Assignment to constant variable.

And because a module runs only once, every importer shares the same count. That makes a module a natural place for shared state, like a single config object or API client.

Dynamic import()#

Static import statements must be at the top level and are loaded before your code runs. Dynamic import() loads a module on demand and returns a promise:

JavaScript
// main.js
const useFancyMath = process.argv.includes("--fancy");

if (useFancyMath) {
  const { circleArea } = await import("./math.js");
  console.log(circleArea(1).toFixed(3)); // 3.142
} else {
  console.log("Skipping the math module");
}
Terminal
node main.js --fancy
Output
3.142

In the browser this is how you lazy-load heavy code — a chart library, an editor, a page of an app — only when the user needs it. Build tools split dynamically imported modules into separate files automatically.

Top-level await#

Inside ES modules you can use await at the top level, outside any async function (you'll learn await properly in the async module):

JavaScript
// config.js
import { readFile } from "node:fs/promises";

const text = await readFile(new URL("./config.json", import.meta.url), "utf8");
export const config = JSON.parse(text);

Any module importing config.js waits until it has finished loading. Use this for one-time setup; avoid slow top-level awaits, since they delay everything that depends on the module.

import.meta.url is the URL of the current module file — useful for loading files relative to it. In Node 20.11+ you also get import.meta.dirname and import.meta.filename.

Common mistakes#

  • Forgetting the extension in Node or the browser: import { x } from "./utils" → Cannot find module.
  • Mixing up default and named imports: import { Cart } from "./Cart.js" when Cart is the default export → does not provide an export named 'Cart'.
  • Using import in a file Node treats as CommonJS (a .cjs file, or a project with "type": "commonjs") → Cannot use import statement outside a module. Add "type": "module" or use .mjs. (Recent Node versions also auto-detect import/export in plain .js files, but being explicit avoids surprises and a small startup cost.)
  • Opening a module page via file:// and wondering why it's blank — check the console for a CORS error and use a local server.
  • Circular imports (A imports B, B imports A) can give you undefined or a Cannot access before initialization error. Move the shared code into a third module.

What's next#

Modules organise your code; now let's organise your data. Next up: Map, Set, WeakMap and WeakSet — collections that go beyond plain objects and arrays.

Check your understanding

Quick quiz

0/3 answered
  1. 1.A file has export default function greet() {} and export const VERSION = 2;. Which import gets both?

  2. 2.How do you tell Node.js to treat .js files in a project as ES modules?

  3. 3.What does await import("./charts.js") do?

Finished reading?

Mark this lesson complete to track your progress.