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.
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.
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).
.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.
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.
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.
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';
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.
.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:
- In browsers: Bare specifiers are not supported directly. They must be mapped to URLs or file paths using a tool like import maps or a bundler (for example, Rollup, Vite, or Webpack).
- In Node.js: Bare specifiers are resolved by looking inside the
node_modulesdirectory and following the module’spackage.jsonconfiguration.
// 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:
.js— Standard JavaScript module (ESM by default in browsers)..mjs— Explicitly treated as an ES module in Node.js..cjs— CommonJS format (legacy Node.js module type).
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.