Skip to content

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:

bash
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:

js
import defaultExport, { namedExport_A } from './with-exports.cjs';

console.log('From with-exports.cjs:');
console.log('- defaultExport:', defaultExport);
console.log('- namedExport_A:', namedExport_A);
js
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');
js
import defaultExport, { namedExport_A } from './without-exports.cjs';

console.log('From without-exports.cjs:');
console.log('- defaultExport:', defaultExport);
console.log('- namedExport_A:', namedExport_A);
js
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.

  1. Execute node import-with.mjs:

    bash
    with-exports module loaded
    From with-exports.cjs:
    - defaultExport: { mainExport: 'main value', namedExport_A: 'namedExport_A', namedExport_B: 'namedExport_B' }
    - namedExport_A: namedExport_A
  2. Execute node import-without.mjs:

    bash
    SyntaxError: 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

  1. Direct Property Assignment

    javascript
    exports.name = 'value';
    module.exports.name = 'value';
  2. Computed Property Assignment (using string literals)

    javascript
    exports['name'] = 'value';
    module.exports['name'] = 'value';
  3. 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

javascript
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

  1. Direct Module Reassignment (only the last one is detected)

    javascript
    module.exports = require('./dep.js');
  2. Spread Re-exports in Object Literals

    javascript
    module.exports = {
      ...require('./dep1.js'),
      ...require('./dep2.js')
    };
  3. Transpiler-Specific Patterns (top-level detection only)

    Babel style:

    javascript
    var _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

  1. 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';
  2. 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);
  3. Limited Object Parsing: Parsing is interrupted upon encountering complex expressions in an object literal

    javascript
    module.exports = {
      a: 'detected',
      b: require('c'), // Complex expression
      d: 'not-detected' // Ignored after the complex expression
    };
  4. 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:

js
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):

js
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.

Contributors

Changelog

Discuss

Released under the CC BY-SA 4.0 License. (dbcbf17)