skip to main content

Node's CommonJS vs. ECMAScript ("ESM") divide is probably the source of most of my quality of life frustrations as a fullstack Typescript/Node/Javascript programmer.

I can often go for weeks at a time before running into new incompatibility problems, so then each time I have to remind myself how interoperability works between them. Well, this time I made a tiny, simple demo so that next time I can just refer to it. And now you can, too!

Short summary of the CommonJS/ESM distinction and problem:

  • CommonJS uses the require('./file.js') syntax for importing other modules and the module.exports = syntax for exporting stuff from modules
  • ESM uses the import {stuff} from './file.js' syntax for importing and the export stuff syntax for exports
  • CommonJS files can use the .cjs extension to tell Node that they are in CommonJS
  • ESM files can use the .mjs extension to tell Node that they are in ESM
  • CommonJS imports are synchronous
  • ESM imports are asynchronous (which also allows for top-level await)
  • CommonJS works in Node but does not work in browsers
  • ESM is supported by all modern browsers and the latest versions of Node, but does not work at all in Node versions below 12
  • Tons of the core JavaScript ecosystem tooling was developed in Node and Node only recently supported ESM, so a huge fraction of existing Node projects are in CommonJS

So that's our situation. Now, to the problem at hand: If you are using ESM, can you import CommonJS? What about the other way around?

In short, YES! But with considerations.

Sample export modules

Let's start with some importable modules. One in CommonJS, the other in ESM:

/**
 * @file `exporter.mjs`
 * (An ESM module exporting a default and named entity.)
 */

export function namedMjsExport() {}

export default function defaultMjsExport() {}
/**
 * @file `exporter.cjs`
 * (A CommonJS module exporting a default and named entity.)
 */

module.exports = function defaultCjsExport() {};

module.exports.namedCjsExport = function namedCjsExport() {};

Importing from ESM and CommonJS to ESM

What does it look like to import both of those modules into another ESM module? Simple! If you are importing into an ESM module, it looks the same either way:

/**
 * @file `importer.mjs`
 *
 * An ESM module that imports stuff
 */

import defaultCjsExport, { namedCjsExport } from './exporter.cjs';
import defaultMjsExport, { namedMjsExport } from './exporter.mjs';

console.log({
  title: 'Importing into an ESM module.',
  defaultCjsExport,
  namedCjsExport,
  defaultMjsExport,
  namedMjsExport,
});

And after we run that script via node importer.mjs (Node v16):

{
  title: 'Importing into an ESM module.',
  defaultCjsExport: [Function: defaultCjsExport] {
    namedCjsExport: [Function: namedCjsExport]
  },
  namedCjsExport: [Function: namedCjsExport],
  defaultMjsExport: [Function: defaultMjsExport],
  namedMjsExport: [Function: namedMjsExport]
}

Perfect! If we're using ESM we can basically treat all code as if it's also ESM. (There are some nuances, but we can usually get away with ignoring them.)

Importing from ESM and CommonJS to CommonJS

So importing into ESM is no biggie, are we so lucky with importing into CommonJS?

NOPE!

Since require() is synchronous, you can't use it to import ESM modules at all! In CommonJS you have to use require syntax for other CommonJS modules and an import() function (distinct from the import keyword used in ESM!), a function that returns a promise, to import ESM.

Let's take a look:

/**
 * @file `importer.cjs`
 *
 * From a require-style Node script, import cjs and mjs modules.
 */

/**
 * Import a module by `require()`ing it. If that results in
 * an error, return the error code.
 */
function requireModule(modulePath, exportName) {
  try {
    const imported = require(modulePath);
    return exportName ? imported[exportName] : imported;
  } catch (err) {
    return err.code;
  }
}

/**
 * CommonJS does not have top-level `await`, so we can wrap
 * everything in an `async` IIFE to make our lives a little easier.
 */
(async function () {
  console.log({
    title: 'Importing into a CommonJS module',

    // CJS<-CJS and MJS<-CJS are equivalent
    defaultCjsExport: requireModule('./exporter.cjs'),
    namedCjsExport: requireModule('./exporter.cjs', 'namedCjsExport'),

    // Cannot `require` an ESM module
    defaultMjsExportUsingRequire: requireModule('./exporter.mjs'),
    namedMjsExportUsingRequire: requireModule(
      './exporter.mjs',
      'namedMjsExport',
    ),

    defaultMjsExport: (await import('./exporter.mjs')).default,
    namedMjsExport: (await import('./exporter.mjs')).namedMjsExport,
  });
})();

And the output of node importer.cjs:

{
  title: 'Importing into a CommonJS module',
  defaultCjsExport: [Function: defaultCjsExport] {
    namedCjsExport: [Function: namedCjsExport]
  },
  namedCjsExport: [Function: namedCjsExport],
  defaultMjsExportUsingRequire: 'ERR_REQUIRE_ESM',
  namedMjsExportUsingRequire: 'ERR_REQUIRE_ESM',
  defaultMjsExport: [Function: defaultMjsExport],
  namedMjsExport: [Function: namedMjsExport]
}

Oh, wow, look at how much more code we needed and how careful we need to be!

Advice

I've been all-in on ESM for a while now. It's a better developer experience and is clearly what we'll be using in the future. But it comes with headaches because so much of the Node ecosystem is still in CommonJS, and you should think carefully before going all-in.

  • Don't forget about the file extensions! Modern Node handles the .mjs and .cjs extensions, so if you need to use one module type in one place and another somewhere else, feel free to mix it up! This also works in Typescript (v4.5+) with the .mts and .cts extensions.
  • (But also note that some tools don't know about those extensions...)
  • Tools written in CommonJS (i.e. most existing Node-based tools) usually handle ESM poorly. Even extremely popular projects. If you want to guarantee that you can use a tool with your code, you may want to stick with CommonJS.
  • If you will mostly be importing other packages into your project (versus having yours imported into others), ESM will let you not have to worry much about what kind of modules you're importing.
  • ESM spec requires that import paths be valid paths, meaning you need the file extension and everything (CommonJS doesn't require that). Node has an option to skip out on that requirement for ESM modules, if you want to keep it old-school: node --es-module-specifier-resolution=node your-dope-module.mjs
  • If you do decide to go all-in on ESM in Node, be ready to do a lot of very annoying troubleshooting!