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
-
ECMAScript Spec — Module Namespace Objects
https://tc39.es/ecma262/#sec-module-namespace-exotic-objects -
Node.js ESM Docs
https://nodejs.org/api/esm.html -
MDN — Namespace Imports
https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Statements/import#namespace_import