Node.js now supports named imports from CommonJS modules, but what does that mean?

simonplend

Simon Plenderleith

Posted on October 2, 2020

Node.js now supports named imports from CommonJS modules, but what does that mean?

A couple of months ago I read the excellent blog post ‘Node Modules at War: Why CommonJS and ES Modules Can’t Get Along‘, and the reasons that CommonJS (CJS) and ECMAScript (ES) modules don’t play nicely together finally started to click for me.

When I saw this tweet the other day about the release of v14.13.0 of Node.js, which introduces support for named exports from CommonJS modules, like many folk I was excited about CJS modules and ES modules working together better.

Tweet by @MylesBorins

There is a huge existing ecosystem of packages for Node.js, many of which only expose a CJS module, not to mention a countless number of applications which are only using CJS modules. Anything which makes it easier to gradually migrate things to ES modules is good news in my book.

After the initial excitement about this release of Node.js subsided, I wondered to myself, "what does this new feature actually mean?". To try and answer that question I installed Node.js v14.13.0 and started messing around with named exports and CJS modules – here’s what I learnt…

Jump Links

First Things First: What are named exports?

Before we dive in to what named exports from CJS modules really means, let’s remind ourselves of what named exports are. ES modules define named exports like this:

export function someFunction() {
    // Some great things would probably happen here
}

export const someObject = {
    // Some interesting object properties would be here
};

export const anotherFunction() {
    // Even greater things would probably happen here
}
Enter fullscreen mode Exit fullscreen mode

And named imports which use them, look like this:

import { someFunction, someObject } from "someModule";
Enter fullscreen mode Exit fullscreen mode

This syntax allows you to only import specific named exports from an ES module – in the example code above we didn’t import anotherFunction.

The Big Gotcha: Named imports don’t work for all CJS modules

If you take a look at the pull request which introduced the named exports for CJS modules feature, you’ll see that a bunch of testing has been done which has shown that it will work for a decent amount of existing packages which expose CJS modules – enough to make it worth shipping this feature. The unstated implication here though is: named exports won’t work for all CJS modules in Node.js v14.13.0.

I learned this the hard way, so you don’t have to 😅

A Comedy of Errors: Trying out named imports with CJS modules

The popular lodash package only exposes a CJS module, so it seemed like a good package to test named imports with:

import { last } from "lodash";

const lastElement = last(["first", "second", "third"]);
console.log(lastElement);
Enter fullscreen mode Exit fullscreen mode

When I ran this code with Node.js v14.13.0, I got this error:

$ node index.mjs

file:///home/simonplend/dev/personal/node-cjs-named-imports/index.mjs:51
import { last } from "lodash";
         ^^^^
SyntaxError: Named export 'last' not found. The requested module 'lodash' 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 'lodash';
const { last } = pkg;
Enter fullscreen mode Exit fullscreen mode

Ok, no big deal. Next I tried using named imports with two other packages which only expose a CJS module, winston and chalk, but I received the same error. Huh.

Being the curious type, I read the the pull request for the CJS named exports feature in more detail and noticed that it’s using a package named cjs-module-lexer. This package will "detect the most likely list of named exports of a CommonJS module". Cool. In the Parsing Examples documentation for this package it mentions that the matching rules it applies to find named exports will "underclassify in cases where the identifiers are renamed". I wondered if this was the reason that I was having problems using named imports.

I dug around in the node_modules directory for my test scripts and looked at the code for each of the packages which I’d tried to use named imports with. Boom! All of the CJS modules exposed by these packages rename the identifier for exports in some way. For winston, the renaming looks like this:

/**
 * Uh oh, the identifier for `exports` has been renamed.
 *
 * This works because objects are assigned by reference in
 * JavaScript, however `cjs-module-lexer` won't be able to
 * detect any named exports that get attached to `winston`.
 */
const winston = exports;

winston.createLogger = require('./winston/create-logger');
Enter fullscreen mode Exit fullscreen mode

I was three CJS packages in, and I still hadn’t found one that I could use with named imports. I did learn however, that even if you can’t use named imports with a CJS package, there is a workaround for this which should always work.

The Workaround: What to do when named imports don’t work for a CJS module

Thankfully, when cjs-module-lexer has been unable to detect named exports for a CJS module and you try and use named imports with that module, the error message it gives you is pretty helpful (you’ll see this error in older versions of Node.js too):

CommonJS modules can always be imported via the default export, for example using:

import pkg from 'lodash';
const { last } = pkg;
Enter fullscreen mode Exit fullscreen mode

The good news is that, as the error message says, you can always import the default export from a CJS module in an ES module e.g. import _ from 'lodash'. The Node.js documentation explains why this works:

The ECMAScript Module Namespace representation of a CommonJS module will always be a namespace with a default export key pointing to the CommonJS module.exports value.

(Source: Modules: ECMAScript modules – CommonJS Namespaces)

Once you’ve imported the default export from a CJS module you can then use destructuring assignment to unpack the named exports from the module object e.g. const { last } = _;

As this workaround only introduces one extra line of code and uses familiar syntax, it feels like a decent approach to me.

The Holy Grail: A CJS module that works with named imports

Back to the emotional rollercoaster: I still hadn’t found a package with a CJS module that worked with named imports in Node.js v14.13.0. Then I gave it a try with express:

import { Router } from "express";

const router = Router();
console.log({ router });
Enter fullscreen mode Exit fullscreen mode

When I ran this I received… no errors! Only PURE SUCCESS! 🎉

$ node express.mjs

{
  router: [Function: router] {
    params: {},
    _params: [],
    caseSensitive: undefined,
    mergeParams: undefined,
    strict: undefined,
    stack: []
  }
}
Enter fullscreen mode Exit fullscreen mode

I had proof at last, typed with my own fingers, in front of my own eyes, that named imports can work for a CJS module in Node.js v14.13.0.

Conclusion

In the Node.js documentation for ECMAScript Modules, under the section about ‘Interoperability with CommonJS’ which covers import statements, it mentions:

Named exports may be available, provided by static analysis as a convenience for better ecosystem compatibility.

(Source: Modules: ECMAScript modules – Interoperability with CommonJS)

This documentation is effectively stating that the feature of named imports for CJS modules is a convenience and it cannot be relied upon. I guess I’d have been less surprised with the results in my testing if I’d seen this documentation beforehand.

I’m keen to figure out if there is a reliable automated way to determine if a CommonJS module is compatible with named imports in Node.js. If that’s possible then you could potentially point a script at a package.json file and have it tell you which CJS dependencies you can use named imports with. That would eliminate a lot of wasted time with trial and error when migrating a project codebase to use ES modules and import syntax.

It wasn’t as straightforward as I had hoped to use named imports with CommonJS modules, but I still think that support for this is a great addition to Node.js – it will certainly help ease the transition to ES modules, but don’t expect it to always "just work".

💖 💪 🙅 🚩
simonplend
Simon Plenderleith

Posted on October 2, 2020

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related