Named exports behavior of CommonJS
Phenomenon
Let's create two nearly identical commonjs
modules for testing. The only difference is that one contains the exports.namedExport_A = Symbol('placeholder')
statement, while the other does not. We will then try to import them by name from an esm
module.
The test file structure is as follows:
test-folder/
├── import-with.mjs # ESM module importing from with-exports.cjs
├── with-exports.cjs # CJS module containing the exports.namedExport_A statement
├── import-without.mjs # ESM module importing from without-exports.cjs
└── without-exports.cjs # CJS module without the exports.namedExport_A statement
The corresponding module code is as follows:
import defaultExport, { namedExport_A } from './with-exports.cjs';
console.log('From with-exports.cjs:');
console.log('- defaultExport:', defaultExport);
console.log('- namedExport_A:', namedExport_A);
function getExports() {
return {
mainExport: 'main value',
namedExport_A: 'namedExport_A',
namedExport_B: 'namedExport_B'
};
}
module.exports = getExports();
exports.namedExport_A = Symbol('placeholder');
console.log('with-exports module loaded');
import defaultExport, { namedExport_A } from './without-exports.cjs';
console.log('From without-exports.cjs:');
console.log('- defaultExport:', defaultExport);
console.log('- namedExport_A:', namedExport_A);
function getExports() {
return {
mainExport: 'main value',
namedExport_A: 'namedExport_A',
namedExport_B: 'namedExport_B'
};
}
module.exports = getExports();
// exports.namedExport_A = Symbol('placeholder'); // This line is removed
console.log('without-exports module loaded');
When executing the modules above, you'll find that the module with exports.namedExport_A
can be imported by name, while the one without it throws an error.
Execute
node import-with.mjs
:bashwith-exports module loaded From with-exports.cjs: - defaultExport: { mainExport: 'main value', namedExport_A: 'namedExport_A', namedExport_B: 'namedExport_B' } - namedExport_A: namedExport_A
Execute
node import-without.mjs
:bashSyntaxError: Named export 'namedExport_A' not found. The requested module './without-exports.cjs' is a CommonJS module, which may not support all module.exports as named exports. CommonJS modules can always be imported via the default export, for example using: import pkg from './without-exports.cjs'; const { namedExport_A } = pkg;
Reason Explanation
node.js
official documentation explicitly states in the Interoperability with CommonJS section:
In addition, a heuristic static analysis is performed against the source text of the CommonJS module to get a best-effort static list of exports to provide on the namespace from values on module.exports. This is necessary since these namespaces must be constructed prior to the evaluation of the CJS module.
In addition, Node.js performs a heuristic static analysis on the source code text of a CommonJS module to get a best-effort static list of exports, which provides values from module.exports on the namespace. This is necessary because these namespaces must be constructed before the CJS module is evaluated.
This means that a javascript
runtime (like node.js
) will first perform static code analysis to predict what a module might export before actually executing the commonjs
module's code. This analysis is not perfect, but it allows esm
modules to have an import experience similar to native esm
modules when importing commonjs
modules, including static structure analysis and named import capabilities.
The cjs-module-lexer
project is what node.js
core uses to detect available named exports when importing a CommonJS
module via ESM
. The identification rules are as follows:
Basic Named Export Detection
Direct Property Assignment
javascriptexports.name = 'value'; module.exports.name = 'value';
Computed Property Assignment (using string literals)
javascriptexports['name'] = 'value'; module.exports['name'] = 'value';
Object.defineProperty Definitions
javascript// Value property Object.defineProperty(exports, 'name', { value: 'value' }); // Getter property (only specific return patterns are supported) Object.defineProperty(exports, 'name', { enumerable: true, get() { return identifier; } }); // Member expression returns are also supported Object.defineProperty(exports, 'name', { enumerable: true, get() { return obj.prop; } }); // Computed member expression returns are also supported Object.defineProperty(exports, 'name', { enumerable: true, get() { return obj['prop']; } });
Object Literal Export Detection
module.exports = {
a, // Shorthand property
b: value, // Regular property
stringKey: value, // String key property
...spread // Spread operator (is ignored, but does not interrupt parsing)
};
Reexport Detection
Direct Module Reassignment (only the last one is detected)
javascriptmodule.exports = require('./dep.js');
Spread Re-exports in Object Literals
javascriptmodule.exports = { ...require('./dep1.js'), ...require('./dep2.js') };
Transpiler-Specific Patterns (top-level detection only)
Babel
style:javascriptvar _external = require('external'); Object.keys(_external).forEach(function (key) { if (key === 'default' || key === '__esModule') return; exports[key] = _external[key]; });
TypeScript
style:javascript__export(require('external')); // or __exportStar(require('external'));
Detection Limitations
No Scope Analysis: May lead to over-detection (identifying non-actual exports as exports)
javascript// Will incorrectly detect 'c' as an export, although the condition never executes if (false) exports.c = 'value';
Identifier Sensitivity: Renaming identifiers in standard patterns may lead to under-detection
javascript// Will not detect any exports because 'e' replaces 'exports' (function (e) { e.a = 'value'; })(exports);
Limited Object Parsing: Parsing is interrupted upon encountering complex expressions in an object literal
javascriptmodule.exports = { a: 'detected', b: require('c'), // Complex expression d: 'not-detected' // Ignored after the complex expression };
Getter Limitations: Only specific
getter
return patterns, such as identifiers or member expressions, are supported.
These rules are carefully designed to efficiently identify common CommonJS
export patterns, especially those generated by popular transpilers, without performing a full syntax analysis.
Ecosystem Application
esbuild
also leverages this feature in its CHANGELOG
:
Add export name annotations to CommonJS
output for node
When you import a CommonJS
file using an ESM
import statement in node
, the default import is the value of module.exports
in the CommonJS
file. In addition, node
attempts to generate named exports for properties of the module.exports
object.
Except that node
doesn't actually ever look at the properties of that object to determine the export names. Instead it parses the CommonJS
file and scans the AST
for certain syntax patterns. A full list of supported patterns can be found in the documentation for the cjs-module-lexer
package. This library doesn't currently support the syntax patterns used by esbuild
.
While esbuild
could adapt its syntax to these patterns, the patterns are less compact than the ones used by esbuild
and doing this would lead to code bloat. Supporting two separate ways of generating export getters would also complicate esbuild
's internal implementation, which is undesirable. Another alternative could be to update the implementation of cjs-module-lexer
to support the specific patterns used by esbuild
. This is also undesirable because this pattern detection would break when minification is enabled, this would tightly couple esbuild
's output format with node
and prevent esbuild
from changing it, and it wouldn't work for existing and previous versions of node
that still have the old version of this library.
Instead, esbuild
will now add additional code to "annotate" ESM
files that have been converted to CommonJS
when esbuild
's platform has been set to node
. The annotation is dead code but is still detected by the cjs-module-lexer
library. If the original ESM
file has the exports foo
and bar
, the additional annotation code will look like this:
0 && (module.exports = { foo, bar });
This allows you to use named imports with an ESM
import statement in node
(previously you could only use the default import):
import { bar, foo } from './file-built-by-esbuild.cjs';
esbuild
achieves this by appending 0 && (module.exports = { foo, bar })
at the end. This effectively guides cjs-module-lexer
to correctly identify the named exports of the CommonJS
module without affecting esbuild
's normal parsing and build behavior.