Skip to content

Modules and Program Structure

A JavaScript module is simultaneously a file, a private scope, and an explicit dependency boundary. That makes modules the natural unit from which programs are assembled. They do not prescribe an architecture, but they give an architecture somewhere concrete to live.

In modern JavaScript, the default module system is ECMAScript modules, usually shortened to ESM. A module can export values:

js
// tax.js
const defaultRate = 0.08;

export function calculateTax(subtotal, rate = defaultRate) {
  return subtotal * rate;
}

export { defaultRate };

Another module imports those values by name:

js
// invoice.js
import { calculateTax } from "./tax.js";

export function totalInvoice(lines) {
  const subtotal = lines.reduce((sum, line) => sum + line.price, 0);
  return subtotal + calculateTax(subtotal);
}

Only exported bindings are visible to importers. Everything else remains private to the module without a class, namespace, or access modifier.

Imports describe dependencies before execution

Static import declarations appear at the top level. The runtime can therefore discover the module graph before evaluating it. Imports are also live, read-only views of exported bindings—not copies:

js
// status.js
export let status = "idle";

export function begin() {
  status = "running";
}

// main.js
import { status, begin } from "./status.js";

console.log(status); // "idle"
begin();
console.log(status); // "running"

The importer cannot assign to status, but it sees later changes made by the exporting module. Exporting mutable module state is occasionally useful, though returning state through functions usually produces a clearer API.

A default export has no exported name:

js
export default function parseInvoice(text) {
  return JSON.parse(text);
}

The importer chooses its local name:

js
import parse from "./parse-invoice.js";

Named exports generally scale better because their names survive refactoring and editor auto-import. Default exports are conventional for some framework components and for a module with one unmistakable product. Neither changes runtime capability.

An index module can present a deliberate public surface:

js
// reports/index.js
export { buildReport } from "./build-report.js";
export { formatReport } from "./format-report.js";

This is useful when it hides internal organization. An index file that blindly re-exports an entire directory makes ownership harder to see and can worsen dependency cycles.

Specifiers are interpreted by the host

The string in an import is a module specifier. Relative specifiers point into the current program:

js
import { loadConfig } from "./config/load-config.js";

Package specifiers are resolved through the environment and package metadata:

js
import express from "express";

URLs work directly in browsers and some other runtimes. Resolution is host behavior, not a property of the language alone. Node ESM requires exact relative filenames, including the extension. Writing them explicitly also makes browser behavior and emitted JavaScript easier to understand.

In Node, a nearby package.json containing "type": "module" makes .js files ESM. The .mjs extension always means ESM, while .cjs always means CommonJS. This course uses "type": "module" and ordinary .js names.

CommonJS is the older Node system, recognizable by require(...) and module.exports. You will encounter it in existing packages and configuration files. Interoperability has edge cases, especially around default and named imports, so inspect a package's documented entry points rather than guessing. New application code rarely benefits from mixing systems casually.

Evaluation is observable

A module runs once when it is first loaded. Top-level work is therefore a side effect of importing it:

js
// telemetry.js
console.log("telemetry initialized");
export function record(event) { /* ... */ }

Simply importing telemetry.js prints. Some initialization is legitimate, but invisible import-time work makes tests, startup ordering, and reuse more difficult. Prefer explicit creation:

js
export function createTelemetry({ destination }) {
  return {
    record(event) {
      destination.write(JSON.stringify(event));
    },
  };
}

Circular imports are legal, but live bindings do not eliminate initialization ordering. If a.js imports b.js and b.js imports a.js, one may observe an uninitialized binding while modules are evaluating. A cycle often indicates that shared concepts belong in a third module or that dependencies point in the wrong direction.

Dynamic import() delays loading and returns a promise:

js
async function loadFormatter(kind) {
  if (kind === "html") {
    return import("./format-html.js");
  }
  return import("./format-text.js");
}

Use it when the dependency is genuinely conditional or expensive, not merely to avoid organizing static imports.

Structure follows boundaries, not file-count rules

Consider a command-line reporting program. Its entry point deals with the host:

js
// cli.js
import { readFile } from "node:fs/promises";
import { buildReport } from "./report/build-report.js";
import { formatReport } from "./report/format-report.js";

const path = process.argv[2];
const input = JSON.parse(await readFile(path, "utf8"));
console.log(formatReport(buildReport(input)));

buildReport can remain ordinary domain logic:

js
export function buildReport(transactions) {
  return transactions.reduce(
    (report, transaction) => {
      report.count += 1;
      report.total += transaction.amount;
      return report;
    },
    { count: 0, total: 0 },
  );
}

The separation is valuable because filesystem access, arguments, and output are environmental concerns; aggregation is not. The entry point composes capabilities. This same shape works for a browser button, an HTTP route, or a test without making the domain module know any of them.

Avoid beginning with directories named controllers, services, managers, and utils merely because large programs sometimes contain them. Start with a few files named for what they own: invoice.js, parse-config.js, format-report.js. Split a file when it contains concepts that change for different reasons or when a useful boundary emerges.

Conventional JavaScript filenames are lowercase, commonly kebab-case.js; component ecosystems often use PascalCase.jsx. Variables and functions use camelCase, constructors and components PascalCase, and true constants sometimes UPPER_SNAKE_CASE. Repository conventions matter more than enforcing every preference universally.

A healthy dependency direction is often:

text
entry point -> environment adapter -> domain logic

Domain logic should accept data and capabilities rather than importing process-wide state. This is dependency injection in its simplest useful form:

js
export function createReportLoader({ readText }) {
  return async path => buildReport(JSON.parse(await readText(path)));
}

No framework is needed. Modules provide enough structure until the program demonstrates a need for more.

Further reference