JavaScript Modules — ES Modules, CommonJS, and the Modern Module Ecosystem

A complete tour of JavaScript modules — the historical landscape (IIFE, AMD, UMD, CommonJS), modern ES modules (export/import, default vs named, re-exports, dynamic import), interop between ESM and CJS in Node, package.json exports, and how TypeScript and bundlers fit in.

The problem a module system solves

For most of its history JavaScript had no module system. Every script shared one global namespace. The solutions were progressively cleverer hacks:

  • IIFE — an immediately-invoked function expression wrapping the code in a private scope, exposing a single global.
  • The Module Patternvar MyLib = (function () { ... })() — closures emulating private members.
  • AMD (RequireJS) — asynchronous loading designed for the browser.
  • CommonJS — synchronous require() born inside Node.js, became the npm standard.
  • UMD — a wrapper that detects whether AMD, CommonJS, or a bare global is in scope and adapts.

In 2015 the language finally added ES Modules (ESM) — a standard, statically analysable, asynchronous module system that works in both browsers and Node.

This post is a working tour of all the pieces that still matter today.

ES Modules — the standard

export and import

A module is a single file. Anything not exported is private to that file.

1// math.js
2export const PI = 3.14159;
3export function double(x) { return x * 2; }
4
5// app.js
6import { PI, double } from "./math.js";
7console.log(double(PI));

Default exports

Every module may have one default export. It is the conventional choice when the module’s primary purpose is to expose one thing — a class, a React component, a config object.

1// logger.js
2export default function log(message) { console.log(message); }
3
4// app.js
5import log from "./logger.js";   // the name is yours to choose

You can combine default and named exports:

1// api.js
2export default fetch;
3export const TIMEOUT = 5000;
4
5// app.js
6import api, { TIMEOUT } from "./api.js";

Renaming

1import { double as twice } from "./math.js";
2export { double as multiplyByTwo };

Re-exports — building a package barrel

1// index.js (a "barrel" that re-exports the public API)
2export { double, square } from "./math.js";
3export { default as Logger } from "./logger.js";
4export * from "./constants.js";

Imports are bindings, not copies

import { count } from "./counter.js" brings in a live, read-only reference. Updates to count inside counter.js are visible at the import site:

1// counter.js
2export let count = 0;
3export function inc() { count++; }
4
5// app.js
6import { count, inc } from "./counter.js";
7console.log(count);   // 0
8inc();
9console.log(count);   // 1   ← the binding is live

You cannot reassign an imported binding from the consumer side.

Modules are singletons

Each module is evaluated once, the first time it is imported anywhere in the program. Every subsequent import receives the same module instance. This makes ESM the cleanest way to share configuration or caches between files.

Modules run in strict mode automatically

No "use strict" directive is needed — and the top-level this is undefined, not the global object.

Dynamic import()

Static imports run at parse time. To load code on demand — for code-splitting, feature flags, or conditional logic — use the import() function. It returns a Promise for the module namespace:

1button.addEventListener("click", async () => {
2  const { renderChart } = await import("./chart.js");
3  renderChart(data);
4});

Bundlers turn each dynamic import into a separate chunk that is downloaded only when needed. In Node, dynamic imports are also the way to load ESM from a CommonJS file.

Top-level await

In ESM only, await works at module top level — handy for initialising values that depend on async work:

1// config.js
2const response = await fetch("/config.json");
3export default await response.json();

Importers wait for the awaited promise before their own code runs.

Node.js — ESM and CommonJS together

Node supports both module systems, but they have different rules.

FeatureCommonJS (.cjs or "type":"commonjs")ES Module (.mjs or "type":"module")
Syntaxrequire / module.exportsimport / export
LoadingSynchronousAsynchronous
__dirname / __filenameAvailableUse import.meta.url + fileURLToPath
Top-level awaitNoYes
File extension required on importsNoYes (in standard Node ESM)

Telling Node which mode a file is in

The closest package.json to the file decides: "type": "module" makes .js files ESM, otherwise they are CommonJS. Use explicit .mjs or .cjs extensions when you need to mix.

Importing CJS from ESM

1// works — Node exposes module.exports as the default export
2import pkg from "some-cjs-package";
3const { foo } = pkg;

Named imports from a CJS module work only when Node can statically detect the exports — which is most of the time, thanks to a small parser, but not always.

Importing ESM from CJS

You cannot use require() for an ESM module from CJS. Use dynamic import():

1const { foo } = await import("some-esm-package");

package.json exports

Modern packages use the "exports" field to control entry points and serve different files to different consumers:

1{
2  "name": "my-lib",
3  "type": "module",
4  "exports": {
5    ".":          { "import": "./dist/index.js", "require": "./dist/index.cjs" },
6    "./utils":    { "import": "./dist/utils.js", "require": "./dist/utils.cjs" },
7    "./package.json": "./package.json"
8  }
9}

This is the single most important file when publishing a dual-format package. Without it, deep imports such as import "my-lib/internal" cannot be controlled and bundle size suffers.

ES Modules in the browser

1<script type="module" src="./app.js"></script>

A type="module" script is:

  • Deferred by default — runs after the document is parsed.
  • Subject to CORS — modules loaded cross-origin require the right headers.
  • Cached at the module level — change one file, only that file re-downloads.

The <link rel="modulepreload" href="./chunk.js"> hint warms the cache for chunks you know you will dynamically import later.

Import maps

Modern browsers support import maps — a JSON block that tells the browser how to resolve bare specifiers like "react":

1<script type="importmap">
2  { "imports": { "react": "https://esm.sh/react@18" } }
3</script>
4<script type="module">
5  import React from "react";
6</script>

This makes bundler-free development viable for many projects.

TypeScript and modules

TypeScript adopted ESM syntax years before Node did, with type-aware extensions:

  • import type { Foo } from "./foo"; — erased at build time, no runtime cost.
  • import { type Foo, bar } from "./foo"; — inline type modifier.
  • export type * from "./types"; — re-export only types.

For new projects use "moduleResolution": "bundler" (TypeScript 5+) so resolution matches what Vite, esbuild, and Rollup actually do.

A pragmatic checklist for a new project

  1. Declare "type": "module" in package.json — be ESM-first.
  2. Use the .mts / .cts (TypeScript) or .mjs / .cjs extensions only when you need to opt out.
  3. Configure "exports" in package.json for any package you publish.
  4. Prefer named exports for libraries; default exports for “the obvious thing” of single-purpose modules.
  5. Use dynamic import() for code-splitting and conditional loading.
  6. In TypeScript, enable "verbatimModuleSyntax" so the emitted JavaScript exactly mirrors your imports — there is no hidden rewriting.

Summary

  • The history is messy (IIFE → AMD → CommonJS → UMD), but the future is ES Modules.
  • ESM is static, strict, asynchronous, and live-bound.
  • Use import() for code-splitting and ESM-from-CJS interop in Node.
  • package.json "type" and "exports" decide how Node and bundlers resolve files.
  • Browsers run modules natively; import maps make bundler-free development practical.
  • TypeScript adds import type and friends; pair with "moduleResolution": "bundler" for new code.

The series ends with What and Why in JavaScript — Advanced Reference — a topic-organised reference for the deep questions that don’t fit neatly into a single tutorial.

Attempted Questions: 0 / 8