JavaScript Module Patterns
debt(d6/e4/b7/t8)
Closest to 'specialist tool catches it' (d5), but better. ESLint and TypeScript catch many patterns (require() in browser code, mixing CommonJS/ESM) automatically, and esbuild will fail visibly on incompatible module patterns. However, subtle issues like circular imports causing undefined values in ESM live bindings require runtime testing or careful code review, pushing this to d6.
Closest to 'simple parameterised fix' (e3), but slightly worse. The quick_fix advises 'use ES modules everywhere + configure type: module in package.json,' which is a single parameter change. However, actual remediation of a mixed codebase requires identifying all require() calls and converting to import statements across potentially multiple files within a module boundary, making it e4 — more than a one-liner but still localized to a component.
Closest to 'strong gravitational pull' (b7). Module choice is load-bearing across the entire codebase: once you commit to CommonJS or ESM, every dependency, build tool, and entry point must align. The choice shapes how the entire project is tooled (esbuild/webpack config), how packages are published, and how all future code is written. Changing module systems mid-project is a codebase-wide refactor.
Closest to 'serious trap' (t7). The core misconception—that CommonJS and ESM are interchangeable—is directly false and contradicts how synchronous/asynchronous module loading works elsewhere (e.g., dynamic imports are async). Developers familiar with require() will assume import works the same way (synchronously, dynamically); the reality (static, asynchronous, parse-time analysis for tree-shaking) breaks that expectation. Circular imports behave differently between the two, adding another gotcha. This is t8, close to catastrophic, because the 'obvious' mental model (they're just different syntax for the same thing) is fundamentally wrong.
Also Known As
TL;DR
Explanation
Module history: IIFE (Immediately Invoked Function Expression) created private scope before native modules. CommonJS (Node.js): synchronous require(), module.exports — still used in legacy Node. AMD (Asynchronous Module Definition): define() for browser async loading — largely replaced. UMD (Universal Module Definition): works in AMD, CommonJS, and global contexts — used in library builds. ESM (ECMAScript Modules, ES2015+): static import/export, tree-shakeable, works in browsers and Node.js — the modern standard. PHP: Composer autoloading is analogous to CommonJS require().
Common Misconception
Why It Matters
Common Mistakes
- Mixing require() and import in the same file — Node.js does not allow this without configuration.
- Not setting type: module in package.json for ESM — .js files default to CommonJS.
- Default exports everywhere — named exports are more refactoring-friendly and tree-shakeable.
- Circular imports causing undefined values — ESM live bindings handle this differently from CommonJS.
Code Examples
// CommonJS — dynamic, not tree-shakeable:
const { helper } = require('./utils'); // All of utils loaded
module.exports = { myFunction };
// Mixing CommonJS and ESM — causes errors:
import { something } from './module'; // ESM import
const other = require('./other'); // CommonJS in same file — Error!
// ESM — static, tree-shakeable:
import { helper } from './utils.js'; // Only helper tree-shaken
export function myFunction() { } // Named export
export default class MyClass { } // Default export
// Dynamic import for code splitting:
const module = await import('./heavy-module.js'); // Loaded on demand
// package.json for ESM Node project:
// { "type": "module" }
// Files use .js extension with ESM syntax