Trust is earned, not given

A different perspective

2019-02-05 · Projects

Node.js, part 5: ES modules arrive — import, export, and the interop rules

Part 5from the Node.js series · 24 parts in all

For years require() was the only way to load code in Node, and it was genuinely different from the import the browser wanted. Node 12 made ES modules work without a flag. Part 5 is the two systems, how they interoperate, and the small set of facts that explains every confusing error you will meet.

Opting a file in

The file extension is the switch: .mjs is always a module, .cjs is always CommonJS, and a plain .js follows the nearest package.json:

{
  "name": "my-service",
  "type": "module"
}

// now every .js in the package is an ES module
import { readFile } from 'node:fs/promises';
import path from 'node:path';          // default import of a CJS module

export function load() { /* ... */ }
export default load;

The differences that actually bite

  1. Imports are hoisted and static. An import cannot be inside an if; that is what the dynamic await import('./plugin.js') is for.
  2. No __dirname, no require, no module.exports. They do not exist in module scope. The bridge is explicit:
    import { createRequire } from 'node:module';
    import { fileURLToPath } from 'node:url';
    
    const require = createRequire(import.meta.url);
    const __dirname = path.dirname(fileURLToPath(import.meta.url));
    
  3. Cycles behave differently. CommonJS hands back a partially filled exports object; ESM resolves bindings live, so a cycle can now see undefined where CJS would have seen a stale value — or the opposite. Either way, when a cycle appears, the fix is to remove the cycle.

Interop, stated once

A module can import a CommonJS package, and the default export is module.exports. CommonJS could not require() an ES module for most of this period — that asymmetry is what made the dual-package problem so annoying, and it is exactly what part 22 fixes.

One practical rule for libraries: shipping both builds means the two copies can be loaded into one process, and a instanceof check across the boundary fails. Keep shared state in a single module, or better, one build. Next: the EventEmitter, Node's original concurrency primitive.