ES Modules (ESM)
debt(d5/e3/b5/t7)
Closest to 'specialist tool catches it' (d5). The detection_hints list eslint and typescript as tools. ESLint with appropriate plugins can catch require() in .mjs files or type:module packages, and TypeScript's module resolution flags mismatches. However, some errors (missing file extensions, live binding surprises) only surface at runtime or require careful configuration, so it doesn't fall to d3.
Closest to 'simple parameterised fix' (e3). The quick_fix describes adding 'type':'module' to package.json and renaming files or updating import paths to include extensions — a systematic but bounded refactor within the project's module configuration, not a one-line patch but not cross-cutting architectural rework either.
Closest to 'persistent productivity tax' (b5). The choice between CJS and ESM shapes how every import/export is written across the codebase, affects bundler configuration, and determines interop complexity in Node.js. It slows multiple work streams (adding dependencies, writing new modules, configuring tools) but doesn't fully define the system's overall architecture.
Closest to 'serious trap — contradicts how a similar concept works elsewhere' (t7). The misconception field explicitly identifies that ESM imports are live bindings, the opposite of CommonJS's value-copy behavior. A developer familiar with CJS will confidently assume the wrong thing. Additionally, missing file extensions silently breaking Node.js resolution and top-level this being undefined contradict common expectations from CJS or browser-global mental models.
Also Known As
TL;DR
Explanation
ES Modules (ESM), standardised in ES2015, replaced the CommonJS require/module.exports pattern. Named exports (export const x = ...) and default exports (export default ...) are imported with import { x } from './mod.js' and import Foo from './mod.js' respectively. ESM is statically analysed — imports are resolved at parse time, not runtime, enabling bundlers to tree-shake unused exports. In browsers, <script type="module"> loads ESM natively with automatic defer semantics and strict mode. In Node.js, ESM files use .mjs extension or "type": "module" in package.json. Key differences from CommonJS: ESM imports are live bindings (reflect mutations in the exporting module), CJS exports are copied values. Dynamic import() returns a Promise and enables code-splitting — loading a module on demand rather than at startup. Import maps (supported in modern browsers) allow remapping bare specifiers like 'react' to a CDN URL without a bundler.
Watch Out
Common Misconception
Why It Matters
Common Mistakes
- Omitting the file extension in Node.js ESM imports — 'import x from './utils'' fails; the extension '.js' is required (no automatic resolution).
- Using require() in a .mjs file or a package with type:module — CJS and ESM cannot be mixed without dynamic import().
- Re-exporting a default as a named export — import Foo from './a'; export { Foo } is needed; export { default } from './a' is the cleaner form.
- Expecting top-level this to be the module object — in ESM, top-level this is undefined (strict mode), not the global or module object.
Code Examples
// CommonJS — no tree-shaking, runtime resolution:
const { format } = require('date-fns');
module.exports = { formatDate };
// ESM — statically analysable, tree-shakeable:
import { format } from 'date-fns'; // bundler can drop unused date-fns exports
export function formatDate(date) {
return format(date, 'yyyy-MM-dd');
}
// In Node.js: file must be .mjs or package.json must have "type": "module"