<< Home

Chapter 10: Modules

As JavaScript applications grew in size and complexity, the need for a standardized way to organize and reuse code became clear. Modules solve this problem by letting you split programs into separate files, each with its own scope and responsibilities. A module can export specific values, functions, or classes for use in other modules, and import what it needs from them in turn.

The modern module system, known as ES modules (or ESM), was introduced in ECMAScript 2015. It provides a consistent, declarative syntax that works both in browsers and in Node.js environments. Modules encourage clean separation of concerns, making your codebase easier to read, test, and maintain.

💡 A module’s top level has its own private scope, so variables defined there are not automatically visible to other scripts. You must explicitly export anything you want to share.

Modules are loaded once per program execution, and their exports are cached. This means that even if you import the same module in multiple places, it is evaluated only once, improving efficiency and predictability.

This chapter explores how to define and use modules with the import and export statements, the difference between default and named exports, how module resolution works, and how modern JavaScript allows await expressions at the top level of a module. It concludes with interoperability notes for working with older systems such as CommonJS and hybrid environments.

ES modules: import and export

ES modules provide a native way to divide code into separate files and share functionality between them. Each module can explicitly define which parts of its code are visible to other modules using the export keyword, and can bring in code from other modules using import. This creates a clear, declarative structure for managing dependencies.

Exporting from a module

Any value, variable, function, or class can be made available to other modules by exporting it. You can export items individually as they are declared, or group them together at the end of the file.

// math.js
export const PI = 3.14159;

export function square(x) {
  return x * x;
}

function cube(x) {
  return x * x * x;
}

export { cube };

In this example, the constants and functions marked with export become accessible to other modules. Anything not exported remains private to math.js.

💡 Each module’s top-level declarations exist only within that module’s scope. Exporting makes selected names visible to the outside world, while keeping everything else safely encapsulated.

Importing into another module

To use exported values from another module, you use the import keyword. You must specify both the names to import and the module file they come from.

// main.js
import { PI, square, cube } from './math.js';

console.log(PI);         // 3.14159
console.log(square(4));  // 16
console.log(cube(3));    // 27

Imported bindings are read-only references to the exported values. You cannot reassign them, but if the exported value is an object or array, its contents can still be modified (subject to normal JavaScript rules).

⚠️ Module paths must include the full filename (including .js) when used in browsers. Node.js allows extensionless imports in certain configurations, but ESM files typically use explicit paths.

Renaming imports and exports

Sometimes you may want to use different names when importing or exporting items. The as keyword provides an alias mechanism for this purpose.

// shapes.js
export function areaCircle(r) {
  return Math.PI * r * r;
}

export function areaSquare(s) {
  return s * s;
}

// main.js
import { areaCircle as circleArea } from './shapes.js';

console.log(circleArea(5));  // 78.5398...

This can help avoid naming conflicts or improve clarity when combining functionality from multiple modules.

Importing everything from a module

You can import an entire module’s exports under a single namespace object using the * as syntax.

// main.js
import * as math from './math.js';

console.log(math.PI);
console.log(math.square(6));

This approach keeps related functions grouped under one name, which can be useful when the module defines many related items.

Import side effects

Sometimes a module performs setup or configuration when it loads, without exporting anything. You can import such a module simply for its side effects by omitting any binding list.

// log-setup.js
console.log('Logging initialized');

// main.js
import './log-setup.js';

This pattern is occasionally used for initializing global settings, polyfills, or event hooks, though modern design usually favors explicit exports and imports.

💡 Modules are evaluated once per program, so side-effect imports run only once no matter how many times the same module is imported elsewhere.

Default vs named exports

ES modules support two kinds of exports: named exports and default exports. Named exports let you export multiple values from a single module, while a default export designates one primary value or object to be exported. Understanding the difference is key to writing clear and consistent module APIs.

Named exports

Named exports explicitly identify each item you want to make available. You can export several items from the same module, and they must be imported using their exact exported names (unless renamed with as).

// utils.js
export function greet(name) {
  return `Hello, ${name}!`;
}

export const VERSION = '1.0';

// main.js
import { greet, VERSION } from './utils.js';

console.log(greet('Robin'));  // Hello, Robin!
console.log(VERSION);         // 1.0

Each module can have as many named exports as you like. These exports are collected into a single export object that can be imported in whole or in part.

💡 Named exports are best when a module provides a set of related utilities or constants rather than a single main feature.

Default exports

Default exports are used when a module has one primary thing to expose, such as a class, function, or configuration object. Only one default export is allowed per module.

// person.js
export default class Person {
  constructor(name) {
    this.name = name;
  }

  greet() {
    console.log(`Hi, I'm ${this.name}`);
  }
}

// main.js
import Person from './person.js';

const robin = new Person('Robin');
robin.greet();  // Hi, I'm Robin

Note that the import statement for a default export does not use braces. You can choose any name you like for the imported value, since it refers to the module’s default export only.

⚠️ Each module may have only one export default declaration. Attempting to declare multiple default exports will cause a syntax error.

Mixing default and named exports

You can combine both default and named exports in the same module, but it is generally clearer to pick one approach per module for consistency. If you do mix them, they can be imported together as follows:

// shapes.js
export const CIRCLE = 'circle';
export const SQUARE = 'square';

export default function draw(shape) {
  console.log(`Drawing a ${shape}`);
}

// main.js
import draw, { CIRCLE, SQUARE } from './shapes.js';

draw(CIRCLE);

Here, draw is the module’s default export, while CIRCLE and SQUARE are named exports. This pattern works well when a module provides one main function plus a few helper constants or types.

Re-exporting from other modules

Modules can also re-export values imported from elsewhere. This helps you create aggregators or index files that gather exports from multiple sources into a single entry point.

// math.js
export const PI = 3.14;
export function square(x) { return x * x; }

// index.js
export { PI, square } from './math.js';

// main.js
import { PI, square } from './index.js';

You can also re-export a default export under your own default or named export using the default keyword:

export { default as Library } from './library.js';
💡 Re-exports help simplify module hierarchies and make it easier to expose clean, public APIs without forcing consumers to import from deep paths.

Module resolution basics

When you use an import statement, JavaScript must locate and load the requested module before execution continues. The process by which it finds the correct file or package is called module resolution. Understanding how this works helps avoid common issues such as missing files, circular dependencies, and unexpected module paths.

Relative and absolute paths

When importing local modules, you typically use a relative path that begins with ./ or ../. This tells the JavaScript engine to look for the module in the filesystem relative to the current file’s location.

import { helper } from './utils.js';
import Logger from '../lib/logger.js';

Absolute paths (starting from the project root or a base URL) can also be used, but they require specific configuration in your environment, such as a module root in Node.js or a base path in a web bundler.

💡 Always include the file extension (.js) when using ES modules in browsers. Node.js can infer the extension in some configurations, but explicit filenames are the safest and most portable choice.

Bare specifiers

When you import a package by name rather than by file path, such as import _ from 'lodash', this is called a bare specifier. Bare specifiers are resolved according to the host environment’s rules:

// Node.js example
import express from 'express';

Here, Node.js looks for an express directory inside node_modules, reads its package.json, and uses the "exports" or "main" field to locate the entry file.

Module file extensions and types

JavaScript modules can have different extensions and formats, depending on their type and the runtime environment:

In Node.js, the type field in package.json determines the default module system for files ending in .js. For example:

{
  "type": "module"
}

With this setting, Node.js treats .js files as ES modules. Without it, Node assumes they are CommonJS files unless they use the .mjs extension.

⚠️ Mixing ES modules and CommonJS files in the same project can lead to resolution problems. Keep file types and import styles consistent across your codebase whenever possible.

Circular dependencies

A circular dependency occurs when two modules import each other, directly or indirectly. When this happens, one of the modules may be partially evaluated, meaning some exports may still be undefined when accessed.

// a.js
import { bar } from './b.js';
export const foo = 'foo';
console.log(bar);

// b.js
import { foo } from './a.js';
export const bar = 'bar';
console.log(foo);

This pattern can cause unpredictable results because of the order in which modules are initialized. If circular dependencies are unavoidable, design them so that shared data flows through functions or objects rather than top-level exports.

💡 The safest way to avoid circular dependencies is to refactor shared logic into a third module that both depend on, rather than importing each other directly.

Caching and re-evaluation

Once a module has been successfully loaded and executed, it is cached. Future imports of the same module return the same instance rather than re-running the module’s code. This ensures consistency across your application and improves performance.

import './setup.js';
import './setup.js';  // runs only once

Because of this caching, any module-level state you maintain (such as counters, configuration, or open connections) persists across all imports within the same runtime session.

Top-level await

Traditionally, the await keyword could only be used inside async functions. However, modern JavaScript allows await to appear directly at the top level of an ES module. This feature, known as top-level await, enables modules to perform asynchronous setup operations before their exports become available to importing modules.

Using await at the top level

When used at the top level, await pauses evaluation of the module until the awaited promise resolves. This makes it possible to perform asynchronous initialization in a clean, linear style without wrapping everything in an async function.

// config.js
const response = await fetch('./settings.json');
export const settings = await response.json();

// main.js
import { settings } from './config.js';
console.log(settings.theme);

Here, the main.js module automatically waits for config.js to finish fetching and parsing its data before continuing execution. This makes asynchronous resource loading straightforward in modular codebases.

💡 Top-level await is supported only in ES modules, not in traditional script files. If you need it, ensure your environment treats files as ESM (for example, with type="module" in HTML or "type": "module" in package.json).

How it affects module loading

When a module uses top-level await, any other module that imports it must wait for it to complete before continuing. This can make module loading asynchronous, even if other modules use only synchronous imports.

// data.js
console.log('Fetching data...');
export const data = await fetch('./data.json').then(r => r.json());
console.log('Data loaded');

// main.js
import { data } from './data.js';
console.log('Data ready', data);

In this sequence, main.js will not log Data ready until the fetch completes and data.js finishes its top-level await.

⚠️ Top-level await can cause import delays if used heavily or across multiple dependent modules. Use it carefully in performance-sensitive code, and consider lazy loading or deferred initialization for large datasets.

Awaiting dynamic imports

Top-level await works especially well with dynamic import() expressions, allowing modules to load dependencies only when needed. The import() function returns a promise that resolves to the imported module’s namespace object.

// main.js
const { default: greet } = await import('./greeter.js');
greet('Robin');

This pattern is often used for conditional imports, feature detection, or optimizing bundle sizes in web applications. Because the syntax is asynchronous, it integrates smoothly with top-level await.

Combining with initialization logic

You can use top-level await to initialize state before exporting anything from the module. For example, you might connect to a database, read configuration files, or prepare API clients.

// db.js
const connection = await connectToDatabase();
export const db = connection;

// main.js
import { db } from './db.js';
db.query('SELECT * FROM users');

This ensures dependent modules receive a fully initialized resource, with no need to manage promises manually.

💡 Top-level await simplifies module setup, but it also means importers must wait for the module to resolve. For predictable performance, reserve it for essential one-time initialization tasks.

Interoperability notes

While ES modules (ESM) are now the standard format for modern JavaScript, many existing projects and libraries still use the older CommonJS (CJS) system, especially in Node.js environments. Understanding how these two systems interact helps ensure compatibility when mixing module types or transitioning older codebases to ESM.

ESM vs CommonJS overview

CommonJS was designed for server-side JavaScript long before ESM existed. It uses the require() function to import dependencies and module.exports or exports to expose values. ES modules, by contrast, use import and export statements that are statically analyzed at load time.

// CommonJS (cjs)
const fs = require('fs');
module.exports = { read: fs.readFileSync };

// ES module (esm)
import fs from 'fs';
export function read(file) {
  return fs.readFileSync(file, 'utf8');
}

CommonJS modules are loaded synchronously, while ES modules are loaded asynchronously. This difference affects how inter-module dependencies are resolved and when exports become available.

⚠️ ESM and CJS cannot freely import each other without adaptation. Mixing the two requires attention to module type settings, file extensions, and runtime behavior.

Importing CommonJS from ESM

In Node.js, ES modules can import CommonJS modules using a default import. The entire module.exports object is exposed as the default export.

// utils.cjs
module.exports = {
  greet(name) {
    console.log(`Hello, ${name}`);
  }
};

// main.mjs (ESM)
import utils from './utils.cjs';
utils.greet('Robin');

This works because Node.js automatically wraps CommonJS exports in a default binding for ES module consumers. Named imports are not supported unless the CJS module explicitly structures its exports as an object.

💡 When importing CJS from ESM, treat everything as a single default object. Use property access (like utils.greet()) to call specific functions or retrieve values.

Importing ES modules from CommonJS

CommonJS cannot use import statements directly. Instead, you can dynamically load an ES module using the asynchronous import() function, which returns a promise that resolves to the module’s namespace object.

// main.cjs
(async () => {
  const { sayHello } = await import('./greeter.mjs');
  sayHello('Robin');
})();

This pattern allows CJS code to interoperate with modern ESM modules while maintaining backward compatibility. It is the recommended approach for gradually migrating legacy code to ES module syntax.

Package configuration and type field

In Node.js, the type field in package.json controls how files with the .js extension are treated:

{
  "type": "module"
}

With this setting, all .js files in the package are interpreted as ES modules. Without it, they default to CommonJS. You can still use .mjs for ESM and .cjs for CommonJS to explicitly control the module type per file.

⚠️ Be consistent with file extensions and package.json settings. Mixing ESM and CJS within the same directory without clear naming or configuration often causes resolution errors.

Browser vs Node.js interoperability

In browsers, only ES modules are supported natively. CommonJS modules must be bundled or converted using tools like Rollup, Webpack, or esbuild. These tools can analyze CJS dependencies and output ESM-compatible bundles.

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

Node.js supports both formats, but requires explicit handling of the type field or file extensions. Browser-targeted ESM files generally work in Node.js as long as they avoid web-specific APIs like document or window.

💡 For maximum compatibility, write all new code in ES module syntax. Use transpilers or bundlers to adapt legacy CJS dependencies when targeting browsers or mixed environments.

Interoperability between ESM and CommonJS is now well supported but can still introduce subtle differences in behavior. Keeping your codebase modular, explicit, and consistent helps ensure that imports and exports behave predictably across environments.