Your Modules Are Lying to You

thunderbong1 pts0 comments

Your Modules Are Lying to You

Your Modules Are Lying to You

Fri Aug 14 2026

tutorials

javascript

nodejs

modules

esm

commonjs

... is lying to you

Consider this small ES module:

// state.mjs<br>export let status = 'starting'

export function start() {<br>status = 'ready'

Import its two exports, call the function, and the imported status changes:

// app.mjs<br>import { status, start } from './state.mjs'

start()<br>console.log(status) // ready

The CommonJS version looks equivalent:

// state.cjs<br>module.exports.status = 'starting'

module.exports.start = function () {<br>module.exports.status = 'ready'

The same-looking consumer produces a different result:

// app.cjs<br>const { status, start } = require('./state.cjs')

start()<br>console.log(status) // starting

The function changed module.exports.status to 'ready', yet the local status remained 'starting'. Keeping the object returned by require() changes the result again:

// app.cjs<br>const state = require('./state.cjs')

state.start()<br>console.log(state.status) // ready

All three results follow from the module systems' contracts. An ESM named import refers to a binding owned by the exporting module. When that module assigns a new value to the binding, the importer observes it. CommonJS returns the value of module.exports, which is an ordinary object in this example. Destructuring reads one property from that object and stores its current value in a local variable. Keeping the object preserves the shared reference, so later property mutations remain visible.

This distinction reaches far beyond destructuring. import and require hide several operations behind the familiar idea of bringing code into a file: resolving a module, creating or retrieving its module instance, evaluating its code, and exposing something to another module. ESM and CommonJS make different decisions at each stage. Those decisions determine when side effects run, what a circular dependency can observe, whether two consumers share state, and why a package can work through import while failing through require.

The examples in this article use native module behavior in current Node.js. Browsers support ESM without CommonJS, while bundlers and transpilers can generate interop wrappers with behavior of their own. Code transformed by a build tool may demonstrate behavior that the Node.js loaders do not guarantee, so each environment should be treated separately.

Bindings, Values, and Objects

An ESM export connects a local name in one module to an imported name in another. The imported name provides a read-only view of the exporter's binding rather than storing a private copy of its value.

// counter.mjs<br>export let count = 0

export function increment() {<br>count++

// app.mjs<br>import { count, increment } from './counter.mjs'

console.log(count) // 0<br>increment()<br>console.log(count) // 1

Only the exporting module can assign to count. The importing module observes each assignment, but cannot perform one itself:

count = 10<br>// TypeError: Assignment to constant variable.

The error mentions a constant because the imported name is read-only in this module. The exporter may declare the same binding with let and update it whenever its own code permits.

Live bindings also say nothing about object immutability. An exported const prevents the exporter from assigning another object to that name, while the object itself may remain mutable:

// settings.mjs<br>export const settings = {<br>mode: 'development'

export function enableProduction() {<br>settings.mode = 'production'

Every importer holding settings can observe the property mutation. Unless the object is frozen or protected behind an API, importers can mutate it as well. The binding is read-only from the importer's perspective; the value stored in that binding may still contain mutable state.

A live binding does not notify dependent code when it changes. Code reads its current value when an expression accesses the imported name, and nothing runs merely because the exporter changed it. Treating an exported binding as application state can therefore create the same coordination problems as any other global mutable state.

CommonJS begins from a different primitive: a module can assign any JavaScript value to module.exports, and require() returns that value. Objects are common, but the export can also be a function, class, string, or promise.

The opening used a mutable property on the exported object. A getter can expose private module state while keeping the same object-based contract:

// counter.cjs<br>let count = 0

module.exports = {<br>get count() {<br>return count<br>},<br>increment() {<br>count++

Compare repeated property access with a destructured value:

// app.cjs<br>const counter = require('./counter.cjs')<br>const { count, increment } = counter

console.log(counter.count) // 0<br>console.log(count) // 0

increment()

console.log(counter.count) // 1<br>console.log(count) // 0

This can resemble an ESM live binding in use, but it comes from ordinary object semantics: counter...

module count state status object console

Related Articles