CommonJS vs ESM: Namespace Handling Explained

When I updated https://github.com/appium/node-teen_process to ESM fully, I met an issue I hadn’t seen. Here is a note about the finding for my future note.

JavaScript’s two module systems — CommonJS (CJS) and ECMAScript Modules (ESM) — look similar on the surface, but they behave very differently when it comes to namespaces, mutability, and monkey‑patching. This difference explains why libraries like teen_process v3 were easy to patch, while newer ESM versions (v4) are not.

CommonJS (CJS)

✔️ Namespace = a plain mutable object

When you import a CJS module:

const tp = require('teen_process');

You get a normal JavaScript object. That means:

  • You can add properties
  • You can delete properties
  • You can reassign properties
  • You can monkey‑patch functions or classes

Example:

tp.exec = wrap(tp.exec);   // ✔️ works
tp.newMethod = () => {};   // ✔️ works
delete tp.SubProcess;      // ✔️ works

This mutability is why many older Node.js tools relied on patching CJS modules.

ES Modules (ESM)

❌ Namespace = immutable module namespace object

When you import an ESM module:

import * as tp from 'teen_process';

You get a module namespace object, which the ECMAScript spec defines as:

  • Frozen
  • Non‑extensible
  • Read‑only
  • Containing live bindings

This means:

tp.exec = wrap(tp.exec);   // ❌ TypeError
tp.newMethod = () => {};   // ❌ TypeError
delete tp.SubProcess;      // ❌ TypeError

You cannot modify the namespace or reassign exports.

The Key Difference in One Line

CommonJS exports a mutable object; ESM exports immutable bindings.

This is the fundamental reason why monkey‑patching works in CJS but not in ESM.

Why This Matters in Real Projects

Example: teen_process

Version Module Type Behavior
v3 CommonJS Patchable — exports were a mutable object
v4 ESM Not patchable — exports are immutable bindings

If your code relied on modifying module exports (e.g., wrapping exec or overriding SubProcess), it will break when the library switches to ESM.

How to Adapt in ESM

You cannot modify the module itself, but you can wrap it:

import { exec as originalExec } from 'teen_process';

export const exec = (...args) => {
  // patch logic
  return originalExec(...args);
};

Or create a wrapper module and import that instead. It also can write like https://github.com/appium/appium-mac2-driver/pull/369 with sinon.

Authoritative References

Leave a Comment

This site uses Akismet to reduce spam. Learn how your comment data is processed.