JavaScript — Modules

Purpose

Modules let you split JavaScript code across multiple files and control what is public. Every serious JS project uses a module system; understanding the two systems (CommonJS and ES Modules) and when each applies is essential for Node.js development.

Implementation Notes

Why Modules Exist

Early JavaScript was small browser scripts. As applications grew, global scope pollution and dependency management became unmanageable. Modules solve:

  • Encapsulation: each file has its own scope; nothing is global unless explicitly exported
  • Dependency management: explicit imports make relationships between files clear
  • Code splitting / lazy loading: bundlers can deliver only the code each page needs

CommonJS (CJS)

Created for Node.js before ES modules. Still widely used in Node.js projects, especially older ones.

// math.js — exporting
const add = (a, b) => a + b;
const subtract = (a, b) => a - b;
 
module.exports = { add, subtract };
 
// Or export one thing as the entire module value
module.exports = function add(a, b) { return a + b; };
// main.js — importing
const { add, subtract } = require("./math.js");
const add = require("./math.js"); // if module.exports is a function
 
console.log(add(1, 2)); // 3

Key characteristics:

  • Synchronousrequire() blocks until the file is loaded (fine for server startup, not for browsers)
  • Dynamicrequire() can be called anywhere, including inside conditions
  • Node.js only — needs a bundler to run in browsers

ES Modules (ESM)

The modern standard, built into the language. Works natively in browsers and Node.js.

// math.js — named exports
export const add = (a, b) => a + b;
export const subtract = (a, b) => a - b;
 
// or export at the bottom
const multiply = (a, b) => a * b;
export { multiply };
 
// Default export — one per file
export default function divide(a, b) { return a / b; }
// main.js — importing
import { add, subtract } from "./math.js";          // named
import divide from "./math.js";                      // default
import { add as sum } from "./math.js";              // rename
import * as MathUtils from "./math.js";              // namespace
 
import divide, { add, subtract } from "./math.js";  // mixed

Key characteristics:

  • Staticimport statements are parsed at load time (enables tree-shaking)
  • Asynchronous — loads don’t block execution
  • Always strict mode"use strict" is implicit
  • Top-level await — available natively

Enabling ESM in Node.js

By default Node.js treats .js files as CommonJS. To use ESM:

// package.json
{
  "type": "module"
}

Or rename files to .mjs (ESM) / .cjs (CommonJS) to mix both in one project.

// With "type": "module", these are ES modules
import { readFile } from "fs/promises";
 
const content = await readFile("./data.txt", "utf8");

Browser Modules

<!-- Without type="module": scripts share global scope, run in order -->
<script src="utils.js"></script>
<script src="app.js"></script>
 
<!-- With type="module": separate scope, deferred, always strict -->
<script type="module" src="app.js"></script>

Module scripts are deferred by default — they run after the document has parsed, so you rarely need defer explicitly.


Node.js Module Resolution

When you write import { x } from "lodash":

  1. Node checks node_modules/lodash/ for package.json"exports" or "main" field
  2. Falls back to index.js if no explicit entry point

Relative imports (starting with ./ or ../) are resolved from the current file. Bare specifiers ("lodash") always resolve from node_modules.


Strict Mode

In non-module scripts, strict mode must be opted into:

"use strict";
// now in strict mode

ES modules are always in strict mode — no declaration needed. This means:

  • Undeclared variables throw ReferenceError (instead of creating globals)
  • this in global scope is undefined
  • Duplicate parameter names are forbidden

Bundlers

Bundlers process your source files and produce optimised output for deployment.

BundlerStrengthsCommon Use
WebpackMature, rich ecosystem, extensive configLarge apps, legacy projects
ViteFast (ESM dev server, esbuild), simple configModern React/Vue/Svelte projects
RollupExcellent tree-shaking, clean outputLibraries
esbuildExtremely fast, Go-basedBuild tool backbone (used by Vite)

What bundlers do beyond bundling:

  • Minification: remove whitespace, shorten variable names
  • Tree shaking: eliminate unused exports (ESM only — requires static imports)
  • Code splitting: lazy-load per-route or per-feature chunks
  • Asset processing: optimise images, inline CSS, generate source maps

Named vs Default Exports

// Named — multiple per file, imported by exact name (or renamed)
export const PI = 3.14;
export function area(r) { return PI * r * r; }
 
// Default — one per file, imported with any name
export default class Circle { ... }

Recommendation: prefer named exports. They make auto-imports in editors unambiguous and are more tree-shake-friendly.

Trade-offs

  • CJS vs ESM: Use ESM for new projects. CJS is still common in older Node.js codebases and is required when consuming packages that don’t ship ESM. Mixing them requires care (.mjs/.cjs extensions or "type" field).
  • Dynamic require() vs static import: CJS’s dynamic require is flexible but opaque to bundlers. ESM’s static import enables tree-shaking and faster startup via analysis.
  • Default vs named exports: Default exports are convenient but lead to inconsistent naming across the codebase. Named exports are explicit.
  • Bundlers: Avoid adding a bundler to a backend Node.js project unless there’s a specific reason — Node.js resolves modules natively. Bundlers pay off for frontend delivery.

References