Complete guide to using ES6 modules to create Node.js packages that are easily usable from CJS modules

; Date: Thu Apr 22 2021

Tags: Node.JS »»»» JavaScript »»»» ES6 Modules

It is very tempting to write all JavaScript code on Node.js with ES6 modules. But that creates a problem of interoperability with the large number of existing CommonJS (CJS) modules. Namely, it is difficult to use ES6 modules from a CJS module. Node.js makes it easy to use both CJS and ES6 modules from an ES6 module, and CJS modules can easily use CJS modules, but a CJS module using an ES6 module is painful. At the same time ES6 modules offer enough attractive benefits to warrant rewriting our code to switch from CJS to ES6 modules. This means we need a strategy for easily using Node.js ES6 modules from CJS modules.

Because Node.js uses the V8 JavaScript engine from the Chrome web browser, it is rapidly updated with most of the latest JavaScript features. Since the V8 team rapidly implements new features, Node.js can quickly adopt those features. For example that meant Node.js programmers can quickly adopt the latest features, like ES6 Modules, even when we might feel reluctant to do so in browser-side JavaScript.

At the beginning of Node.js, the CommonJS (CJS) module format is what we used. It was the only available module format at the time Ryan Dahl created Node.js, so that's what he used. But, ECMAScript 6 brought many fantastic improvements to JavaScript, one of which is a common module format, ES6 Modules, that is portable to both browser-side and server-side JavaScript. If nothing else, the primary reason to consider rewriting CJS modules as ES6 modules is to gain benefits from having the same module format in both browser and server JavaScript.

But the pragmatic practicality of software engineering is that CommonJS code on Node.js will probably never go away. It is expensive to rewrite working code, and not all Engineering managers will see the wisdom of doing so. That means we must work out best practices for compatibility between ES6 and CJS modules.

The interoperability between ES6 and CJS modules boils down to this:

  • In an ES6 module, we use the import statement to load both ES6 modules and CJS modules.
  • In a CJS module, we use the require statement to load CJS modules.
  • In a CJS module, we normally cannot load ES6 modules, but the Dynamic Import feature gives us the import() function which can load ES6 modules.

You might be thinking "what's the problem?" Both module formats can load modules of the other format. The issue is that the import() function returns a Promise, making it difficult to follow the normal pattern of assigning the module object to a global variable. With both the import and require statements it's trivially easy to assign the module reference to a global variable that's easy to use. But because import() is asynchronous, and returns a Promise, the global variable we would use will have the value undefined for awhile, which our code must accommodate.

const es6module = import('./es6odule.mjs');

If this were a require statement, we'd be able to immediately use the module. That's because require is a synchronous operation, meaning that when require finishes the module is fully loaded. But, loading ES6 modules using import() is asynchronous. In practice that means es6module, in this example, will hold a Promise which is returned by the import() function. To use that Promise requires either the await keyword, or a .then block. Neither of those are convenient.

There are two general strategies for using ES6 modules from CJS modules:

  • If the function is asynchronous, it can be left in an ES6 module, which can be imported using import(). Invoking that function can then accommodate the Promise returned by the import() function.
  • If the function is asynchronous, the ES6 module implementing the function must be rewritten as a CJS module, so that the CJS module can use require. It is easiest to use a transpiler, like Babel, to do this.

In this article we will study the interoperability of Node.js packages using both ES6 and CJS modules. In particular we will learn about creating hybrid Node.js packages that export their API using both CJS and ES6 modules.

For some background information see these articles:

Using Dynamic import in Node.js lets us import ES6 modules in CommonJS code, and more - UPDATED With ES6, JavaScript programmers finally gained a standard module format that worked on both browser-side and server-side (Node.js) JavaScript runtimes. Plus, ES6 modules offer many interesting features. But Node.js programmers need to know how to integrate ES6 modules with the CommonJS module format we're accustomed to using. Now that Node.js v14 is coming, and bringing with it full ES6 module support as a standard feature, it is time to update a previous blog post about Dynamic Import on Node.js. The Dynamic Import feature, a.k.a. the import() function, gives JavaScript programmers much more freedom over the standard import statement, plus it lets us import an ES6 module into CommonJS code.

Loading an ES6 module in a Node.js CommonJS module

ES6 modules are a nifty new way to write modules. They're rather similar to the CommonJS module format traditionally used by Node.js, but there are significant differences. When ES6 module support was first added to Node.js, it was an experimental feature with significant limitations. One limitation was whether you could use ES6 modules from CJS modules, and there was a 3rd party package to fill that gap. Today, ES6 modules are a first class feature in Node.js, and ES6 modules can be used from CJS without any add-on support.

Loading modules in both CJS and ES6 modules

Before going further, let's review how modules are loaded in both CJS and ES6 code. The CommonJS module format (CJS) was the only choice available when Ryan Dahl created Node.js. Since the CJS module format was the only choice available its use has a lot of inertia.

For a CJS module to load another CJS module:

const fsextra = require('fs-extra');
const path = require('path');
const fs = require('fs/promises');
const apputil = require('./app-utils.js');
const myLocalModule = require('./path/myLocalModule');
const jsonData = require('./path/filename.json');

These are a few examples of using the CJS require statement. The first three load a package installed in a node_modules directory. The next three reference local module files. The require does not require specifying the full file name, with extension, because Node.js will intuit what to do based on the files it finds in the filesystem. We can also load JSON data using require.

The fs module example shows loading a subsidiary package of an installed package. That is, contains file system API functions, and its primary API uses the traditional callback-style functions. As a convenience, the fs/promises sub-package reimplements the same API functions to return Promise's for easy use in async functions.

The fs package is not the only one supporting subsidiary packages. In Node.js, accessing a subsidiary package requires the correct require specifier, which follows the pattern module-name/sub-module-name.

As we said earlier, the require statement is synchronous, meaning the module is fully loaded when require finishes. The module is not lazy-loaded in the background, but is available for immediate use. We'll see later why that's significant.

For an ES6 module, we use the import statement to load either CJS or ES6 modules:

import fsextra from 'fs-extra';
import path from 'path';
import { promises as fs } from 'fs'; // alternatively: import { * as fs } from 'fs/promises';
import { unlink as fsunlink } from 'fs/promises';
import apputil from './app-utils.js';
import myES6Module from './path/myES6Module/index.mjs';

The ES6 import statement has a lot of capabilities, and we've only scratched the surface with these examples. Like with CJS, a simple module specifier like path simply loads the installed path module. The { foo } notation lets you load only a specific item from the named module. The { foo as foobar } notation lets you give it a different name. For example, we've given the promises object from the fs module the name fs, and the unlink function from the fs/promises module the name fsunlink.

The import module specifier is actually a URL. Node.js supports file: URL's, and does not support http: URL's for security reasons. You can install an HTTP URL handler if that's what you want. As a convenience, Node.js transparently converts file names into file: URL's.

To load data files, like JSON, requires using a data: URL. We won't go over that here.

Getting back to CJS modules, we said loading an ES6 module required the import() function. To do this:

import('./path/myES6Module/index.mjs')
.then(myES6Module => {
    // use the module
})
.catch(err => {
    console.error(err);
});

// Alternative
(async () => {
    const myES6Module = await import('./path/myES6Module/index.mjs');

    // use the module
})().catch(err => console.error(err));

// Alternative
let myES6Module;
(async () => {
    myES6Module = await import('./path/myES6Module/index.mjs');
})().catch(err => console.error(err));

Because loading ES6 modules is asynchronous, the import() function is asynchronous. This means the import() function returns a Promise. You can either handle the Promise with .then/.catch blocks, or using the await keyword in an async function. In both cases the module reference is delivered inside a function, making it difficult to make a global variable which reliably holds the reference. The global variable, myES6Module, will either have the value undefined, or else the module reference. Any code using that global variable will have to detect which value it currently has, and accommodating the times it does not contain the module reference.

The other pattern was mentioned earlier:

const myES6Module = import('./path/myES6Module/index.mjs');

The variable myES6Module will contain a Promise, and eventually that Promise will transition to either the resolved or rejected state. But you're left with the problem of waiting for the module to finish loading, and handling any error if the module fails to load. That means using a .then/.catch block, or the await keyword in an async function.

Now that we've set up the context, let's explore the solutions. We'll first learn how to accommodate module references that can be either undefined or an unresolved Promise or a resolved Promise.

Hybrid Node.js package written using ES6 modules, with CJS module containing the public API

There are multiple methods to making an ES6 package easily usable from a CJS module. In this section we'll start with implementing a CJS wrapper module around an ES6 module. That is we'll write our code using ES6 modules, but for use from a CJS module we'll provide a thin CJS module whose functions simply call the corresponding functions in the ES6 module. To load the ES6 module requires using the import() function.

To give us a solid foundation from which to discuss things, let's consider a package picked at random from a node_modules directory:

$ tree node_modules/micromatch/
node_modules/micromatch/
├── CHANGELOG.md
├── LICENSE
├── README.md
├── index.js
└── lib
    ├── cache.js
    ├── compilers.js
    ├── parsers.js
    └── utils.js

The files in lib are what I would call interior modules. The code in index.js is the public API. Its code uses the functions in the interior modules, but those modules are not expected to be used by other code outside the package.

The first step would be to refactor lib/**.js modules to be ES6 modules. That requires more than just renaming the files, but changing any assignment to the module.exports object to an equivalent export statement. In ES6 modules, data and functions are exported via the export statement.

$ tree node_modules/micromatch/
node_modules/micromatch/
├── CHANGELOG.md
├── LICENSE
├── README.md
├── index.js
└── lib
    ├── cache.mjs
    ├── compilers.mjs
    ├── parsers.mjs
    └── utils.mjs

That leaves us with index.js as a CJS module that we need to make usable by both ES6 and CJS module, and the interior modules are in ES6 format. Since we've done nothing in index.js the module will fail as it stands.

In index.js we see this block of code:

var compilers = require('./lib/compilers');
var parsers = require('./lib/parsers');
var cache = require('./lib/cache');
var utils = require('./lib/utils');

This is what we expect for a CJS module to load interior CJS modules. The first step is this conversion:

const compilers = import('./lib/compilers.mjs');
const parsers = import('./lib/parsers.mjs');
const cache = import('./lib/cache.mjs');
const utils = import('./lib/utils.mjs');

With the require statement we are able to leave off the file extension, and Node.js intuits the correct thing to do. With the ES6 import statement or import() function, we are required to provide the file extension. We've also changed the var to const because it's a best practice to use const for this purpose.

The key thing to remember is that these global variables now have Promise objects rather than module references. Because of this, the package will still fail because existing references to these objects will not find the expected module reference.

A problem comes up as we examine the code further and find this:

function micromatch(list, patterns, options) {
  patterns = utils.arrayify(patterns);
  list = utils.arrayify(list);
  ...
}

Any path we use to rewrite this function requires converting it from a synchronous function to asynchronous. The micromatch package this example comes from clearly exports synchronous functions, and it doesn't make sense for its API to switch over to asynchronous functions.

For example, one option is:

async function micromatch(list, patterns, options) {
  patterns = (await utils).arrayify(patterns);
  list = (await utils).arrayify(list);
  ...
}

Using the await keyword is the cleanest way to wait for the Promise object to resolve. But using it requires redeclaring the micromatch function as async, and callers to that function must then be rewritten for that. The same would be true if we used .then/.catch to wait for the Promise object to resolve.

Clearly if the micromatch function was asynchronous then we could have pulled this off. This is why earlier we described the distinction between asynchronous and synchronous functions. In order for micromatch to remain a synchronous function it cannot perform any asynchronous actions.

Let's change direction slightly, and consider a different package we might call asyncmatch.

$ tree node_modules/asyncmatch/
node_modules/asyncmatch/
├── CHANGELOG.md
├── LICENSE
├── README.md
├── index.js
└── lib
    ├── compilers.mjs
    └── utils.mjs

We're changing the name and content a bit for the sake of discussion, to consider a package that exposes an asynchronous API. As before, index.js would have this:

const compilers = import('./lib/compilers.mjs');
const utils = import('./lib/utils.mjs');

Then, when index.js starts implementing API functions it would follow this pattern:

async function asyncmatch(list, patterns, options) {
  patterns = (await utils).arrayify(patterns);
  list = (await utils).arrayify(list);
  ...
}

As an async function, we can easily use the await keyword in asyncmatch. Therefore (await utils).arrayify automatically handles the Promise in the utils variable.

Hence, this is one pattern for implementing a CJS module that invokes functions in ES6 modules. The public API is a CJS module which is automatically easy to use by any Node.js application. That the interior modules are in ES6 module format does not limit which applications can use the package.

To make this a little more real, let's try some actual code. The code snippets are available in a repository at (github.com) https://github.com/robogeek/async-javascript-examples in the directory named hybrid-es6-cjs-module.

We start with an ES6 module, hello.mjs, containing:

export async function hello() {
    return "Hello, world!";
}

Because this is declared as an async function it returns a Promise.

Then we create a CJS module, index.js, containing:

const aasync = import('./async.mjs');

console.log(aasync);

async function hello() {
    let ret = (await aasync).hello();
    console.log(aasync);
    return ret;
}
module.exports.hello = hello;

The first part uses import() to load the ES6 module. We then implement an async function that's to be the public interface for the package. This hello function calls the hello function in async.mjs.

This is of course highly artificial. The point is that we've created an async function in a CJS module, that exposes another async function in an interior ES6 module. Any application using this package will use the API in index.js. The gain is that index.js is in CJS format and can be easily used from other CJS modules.

The console.log statements are there so we can inspect the content of the module reference in aasync at different points of time.

To test this, create a file named test.js containing:

const cjshello = require('./index.js');

async function callHello() {
    let h = await cjshello.hello();
    console.log(h);
}

callHello()
.then(() => { console.log('callHello SUCCESS'); })
.catch(err => { console.log('callHello ERROR ', err); })

This script is meant to simulate an application using this package, and the callHello function is meant to simulate using this package. It calls the hello function, using await so every Promise is handled, and then prints the result. Then at the bottom we invoke callHello using .then and .catch blocks to handle the Promise.

The output looks like this:

$ node test.js 
Promise { <pending> }
Promise { [Module] { hello: [AsyncFunction: hello] } }
Hello, world!
callHello SUCCESS

We see that in the first console.log the Promise is in pending state as expected. But the await keyword, as expected, waits until the Promise is successfully resolved. We get both the expected message, as well as the SUCCESS indicator. The second console.log, as expected, shows that the Promise is resolved, and that it contains a Module object.

Suppose your public API needed to use the callback style of function. Add this to index.js:

function helloCB(done) {
    aasync.then(m => {
        m.hello()
        .then(msg => { done(undefined, msg); })
        .catch(err => { done(err); });
    })
    .catch(err => {
        done(err);
    });
}
module.exports.helloCB = helloCB;

Remember that aasync contains a Promise returned from the import() function, which we handle using a .then/.catch block. Within the .then we invoke the hello function, again with .then/.catch blocks. A callback function is provided, which we use in the error first pattern that's typical of Node.js callback functions.

This should stand as a reminder of the huge gain async/await functions are. To implement this without using an async function meant using two layers of .then/.catch blocks to handle each Promise. Ensuring catching every error takes some patience to add every required .catch block.

Then we can refactor test.js a little to this:

callHello()
.then(() => { console.log('callHello SUCCESS'); })
.catch(err => { console.log('callHello ERROR ', err); })

cjshello.helloCB((err, msg) => {
    if (err) {
        console.log('helloCB ERROR ', err);
    } else {
        console.log('helloCB SUCCESS ', msg);
    }
});

The messages now indicate which function prints the message. The resulting output looks like this:

$ node test.js 
Promise { <pending> }
Promise { [Module] { hello: [AsyncFunction: hello] } }
helloCB SUCCESS  Hello, world!
Hello, world!
callHello SUCCESS

We've just proved something important. That we can use the ES6 module format to implement the interior modules of a Node.js package, and expose a public API using a CJS module. However, this approach is only suitable when the exposed API is already asynchronous.

Using Conditional Exports in Node.js package.json for hybrid CJS/ES6 modules

We've proven that we can use ES6 modules inside a Node.js package, and create a CJS interface module enabling the module to be easily used from CommonJS code. But it would be better for ES6 modules to directly load an ES6 module, rather than to work through an intermediary CJS module.

Starting with Node.js v12.7.0, we became able to add an exports field to package.json. One purpose for this field is to declare how to handle subsidiary modules. For example:

{
    "name": "module-name",
    ...
    "main": "index.js",
    "exports": {
        ".": "./index.js",
        "promises": "./promises/index.js"
    },
    ...
}

This says the primary interface to the package is index.js, and that a package reference of module-name/promises resolves to promises/index.js.

A variation of this feature is Conditional Exports, which maps to different file paths based on various conditions. The condition we'll use is whether the module is being loaded using require or import. Namely, the above could be written as:

{
    "name": "module-name",
    ...
    "main": "index.js",
    "exports": {
        ".": {
            "require": "./index.js",
            "import": "./index.mjs",
        },
        "promises": {
            "require": "./promises/index.js",
            "import": "./promises/index.mjs",
        }
    },
    ...
}

This is the nested form of conditional exports. For each, we have two fields require and import. The require field tells Node.js where to look when the module is requested using the require statement, and the import field is used from an import statement.

Let's look at a real example. In the (github.com) https://github.com/robogeek/async-javascript-examples repository you'll find a directory named conditional-exports. Within that directory is a directory, module, containing:

$ tree module/
module/
├── index.js
├── index.mjs
└── package.json

This is a simplistic Node.js package, containing a package.json, a CJS module with the public API in index.js and an ES6 module in index.mjs.

To start with index.mjs:

export async function hello() {
    console.log('in ES6');
    return "Hello, world!";
}

If this were a real Node.js package the module would have more interesting functionality than this, but this is enough for exploring the feature. Namely, we have our asynchronous function.

In index.js is the following:

const _index = import('./index.mjs');

module.exports.hello = async function() {
    console.log('in CJS');
    return (await _index).hello();
}

This is a wrapper around the ES6 module in index.mjs. The pattern is, for every function in interior modules, to implement a wrapper function like this in index.js.

Suppose your package needs to support a synchronous function. We cannot implement it in index.mjs then use a wrapper function in index.js. Instead, create a file named shared.js containing:

module.exports.func1 = function() {
    return "Synchronous shared function";
}

Because this synchronous function is in a CJS module, it can be loaded into index.js using require, and loaded into index.mjs using import. In both cases it will remain as a synchronous function.

In index.mjs we export this function this way:

export { func1 } from './shared.js';

This is what's called re-exporting, where the export statement says it is exporting a thing imported from another module.

In index.js we'll do the same, but it requires two steps:

const shared = require('./shared.js');
...
module.exports.func1 = shared.func1;

Here we use require to load the shared module, then add its func1 to module.exports.

To round this off, package.json:

{
  "name": "conditional-exports-module",
  "main": "index.js",
  "exports": {
    "require": "./index.js",
    "import": "./index.mjs"
  },
  "type": "commonjs"
  ...
}

This is the simpler form of Conditional Exports, where there is only one export.

The type field says to treat files with the .js extension as CommonJS. If instead the type field said module, then using CJS files would require the .cjs extension, and files with the .js extension would be interpreted as ES6 modules.

To test this, create another directory named test as a sibling of the module directory, containing:

$ tree test/
test/
├── package.json
├── test.js
└── test.mjs

We'll use test.js to verify loading the CJS interface, and use test.mjs to verify loading the ES6 interface.

In test.mjs we have:

import * as condit from "conditional-exports-module";

console.log(condit);
console.log(await condit.hello());

console.log(condit.func1());

Notice that in module/package.json we used this module name for the package. This simply uses the import statement to load the module, and then we print the results of calling the hello function. Because that function is async we use await.

In case you're not aware, Node.js recently acquired the ability to use await at the top-level of ES6 modules. That's what we've done here. To learn more, see: Node.js Script writers: Top-level async/await now available

And, in test.js we have:

const condit = require('conditional-exports-module');

console.log(condit);
condit.hello()
.then(msg => { console.log(msg); })
.catch(err => { console.error(err); });

console.log(condit.func1());

Notice that we've used require rather than import(). That means we can directly use the module reference without having to disambiguate any Promise object. Because the hello function is defined as asynchronous, and top-level await is not available in CJS modules, we have to deal with the resulting Promise using .then/.catch blocks.

The package.json required is:

{
  "name": "conditional-exports-test",
  "main": "test.js",
  "scripts": {
    "test": "npm run test:js && npm run test:es6",
    "test:js": "node test.js",
    "test:es6": "node test.mjs"
  }
  ...
}

We can use the scripts field to help us run the test programs, if nothing else.

To set this up, first run:

$ cd test
$ npm install ../module

This should create a node_modules directory and install the package there. Next run:

$ npm test

> conditional-exports-test@1.0.0 test /Volumes/Extra/nodejs/async-javascript-examples/conditional-exports/test
> npm run test:js && npm run test:es6

> conditional-exports-test@1.0.0 test:js /Volumes/Extra/nodejs/async-javascript-examples/conditional-exports/test
> node test.js

{ hello: [AsyncFunction (anonymous)], func1: [Function (anonymous)] }
in CJS
Synchronous shared function
in ES6
Hello, world!

> conditional-exports-test@1.0.0 test:es6 /Volumes/Extra/nodejs/async-javascript-examples/conditional-exports/test
> node test.mjs

[Module] {
  func1: [Function (anonymous)],
  hello: [AsyncFunction: hello]
}
in ES6
Hello, world!
Synchronous shared function

We see that to run the test.js script, it first calls the function in the CJS module, and then the function in the ES6 module. FOr the test.mjs script it only called the ES6 function.

This is exactly what we wanted. First, we have parallel implementations of the package in both CJS and ES6 module format. We can easily use either one from CJS or ES6 modules, and easily access the appropriate implementation.

There is a third scenario to test, which is to use import() from a CJS module. For that purpose create test-es6.js containing:

const _condit = import('conditional-exports-module');

console.log(_condit);

_condit
.then(condit => {
    console.log(condit);
    condit.hello()
    .then(msg => { console.log(msg); })
    .catch(err => { console.error(err); });
    
    console.log(condit.func1());
})
.catch(err => { console.error(err); });

Notice that for each of these the module specifier has stayed the same. The magic is happening in package.json.

In any case, remember that import() returns a Promise, which must first be resolved. We then have an asynchronous function, hello, which returns a Promise that must be resolved. Hence, the test has two layers of .then/.catch to be correctly built.

Running the test we get:

$ node test-es6.js 

Promise { <pending> }
[Module] {
  func1: [Function (anonymous)],
  hello: [AsyncFunction: hello]
}
in ES6
Synchronous shared function
Hello, world!

Which is again exactly what we wanted. Because we used import(), the function call went direct to the ES6 implementation, skipping the CJS wrapper module.

To implement a synchronous function we put it into a shared CJS module. But what if we want all our code base to be in ES6 modules, while keeping some (or all) of the functions synchronous? Clearly we cannot do this by using the import() function to access those functions from a CJS module.

There is another method, which is to use a Transpiler like Babel to rewrite ES6 modules to CJS.

Transpiling a hybrid CJS/ES6 Node.js package with Babel

We haven't quite developed the best way to create hybrid Node.js packages containing both CJS and ES6 implementations. We've shown that we can hand-craft a CJS module with the same API as an ES6 module. But there's another issue, which is maintainability. It's not any kind of best practice to hand-craft the CJS wrapper module, since we might neglect to update the wrapper on every API update.

Within the JavaScript ecosystem is an amazing tool, Babel, which is a Transpiler. The word is a mashup of Translate and Compiler, and is about converting source code from one programming language to another programming language. In the case of Babel its focus is on JavaScript conversion, such as being a tool to automatically convert cutting-edge JavaScript features to run on older JavaScript engines. In this case we will use it to convert our ES6 module into a Node.js CommonJS module.

To get started duplicate the module directory, and its contents, as module-babel. You'll find this already in the Github repository referenced earlier.

Delete both index.js and shared.js since we no longer need them. And, in index.mjs add this function:

export function func1() {
    return "Synchronous ES6 function";
}

That's a reimplementation of func1 which had been in shared.js. That leaves us with one file, index.mjs, containing our entire API implementation.

To install Babel, run these commands:

$ npm install @babel/core --save-dev
$ npm install @babel/cli --save-dev
$ npm install @babel/plugin-transform-modules-commonjs --save-dev

This installs Babel, and it's CLI tool. The plugin named here transforms ES6 module code to CommonJS format.

Next, create a file named .babelrc containing:

{
    "plugins": [
      "@babel/plugin-transform-modules-commonjs"
    ]
}

This configures Babel to use that plugin.

Next, add the following scripts entry to package.json:

"scripts": {
    "build": "babel index.mjs --out-file index.js"
},

This is the command for compiling a single ES6 file to CommonJS format. Babel has a lot more options than this, and this is a very simplistic use of Babel.

Run the script:

$ npm run build

> conditional-exports-module@1.0.0 build /Volumes/Extra/nodejs/async-javascript-examples/conditional-exports/module-babel
> babel index.mjs --out-file index.js

And the result is:

"use strict";

Object.defineProperty(exports, "__esModule", {
  value: true
});
exports.hello = hello;
exports.func1 = func1;

async function hello() {
  console.log('in ES6');
  return "Hello, world!";
}

function func1() {
  return "Synchronous ES6 function";
}

This is a very reasonable CJS rendition of the module. The only problem is the tracing output says in ES6 when that's no longer correct. Oh well.

Let's now set up the test directory again. There may be an existing node_modules directory installed with the package in ../module. We now want to use ../module-babel, so run this:

$ cd ../test
$ rm -rf node_modules
$ npm install ../module-babel

We can now run the test programs and get this output:

> conditional-exports-test@1.0.0 test:js /Volumes/Extra/nodejs/async-javascript-examples/conditional-exports/test
> node test.js

{ hello: [AsyncFunction: hello], func1: [Function: func1] }
in ES6
Synchronous ES6 function
Hello, world!

> conditional-exports-test@1.0.0 test:es6 /Volumes/Extra/nodejs/async-javascript-examples/conditional-exports/test
> node test.mjs

[Module] { func1: [Function: func1], hello: [AsyncFunction: hello] }
in ES6
Hello, world!
Synchronous ES6 function

The differing order for the output is a simple byproduct of hello being an asynchronous function. In any case, we got exactly the output we expected.

Implementing packages in TypeScript, compiling to both CJS and ES6 modules

The ECMAScript additions to the JavaScript language are not the only game in town. Another major player is the TypeScript language. It's module format is similar to the ES6 format, and we can directly reuse an ES6 file as a TypeScript file. What TypeScript brings to the game is stronger type checking than the JavaScript language. Yes, JavaScript programmers are accustomed to having essentially no type checking. But as our programs get larger and larger it's useful for the compiler to help us by static checking our code, which is what TypeScript's compiler does.

Some programming teams will buy into this idea, and therefore development is done in TypeScript. However, the runtime is still JavaScript meaning one must compile the code before running it. Some JavaScript programmers find this strange, but those of us who've coded in Java or C or Pascal or PL/1 or many other compiled languages are used to the drill.

The TypeScript compiler supports compiling to many different output formats. With suitable configuration we can coax it into producing CommonJS files for and ES6 files for Node.js.

Let's duplicate the module directory as module-typescript. Then delete module-typescript/index.js and rename module-typescript/index.mjs to index.ts. Because TypeScript can compile ES6 modules, there are no further changes to make. It's worth learning about TypeScript's long list of useful features, however.

The file extension for TypeScript is .ts, and in this directory we'll be coding in the .ts file. We'll use the compiler to produce .cjs and .mjs files.

To install the TypeScript compiler, run this command:

$ npm install typescript --save-dev

To familiarize, type: tsc --help

In package.json add this scripts section:

"scripts": {
    "build": "npm run build:cjs && npm run build:es6",
    "build:cjs": "tsc --target es2018 --module commonjs index.ts && mv index.js index.cjs",
    "build:es6": "tsc --types --sourceMap --declaration --declarationMap --target es2018 --module es2020 index.ts && mv index.js index.mjs"
},

The --target option describes the version of the ECMAScript language to use. Using es2018 gives us the language features we need.

The --module option describes the module format to use. For Node.js, we use commonjs, and for ES6 modules we use es2020.

The --types --sourceMap --declaration --declarationMap options tell TypeScript to generate metadata files describing the types used in the files. Those types can be used when compiling other packages to help TypeScript do better type checking.

As with Babel, we are barely scratching the surface of what the TypeScript compiler can do. In any case, the next task is to run npm run build to generate the runnable CJS and ES6 files.

Finish package.json by adding these declarations:

"main": "index.js",
"exports": {
    "require": "./index.cjs",
    "import": "./index.mjs"
},
"type": "commonjs",
"types": "./index.d.ts",

Notice that we're using the file extension .cjs for the CJS module. That's because we were unable to work out how to coax tsc to output the compiled file to a specific file name. Instead, notice that it outputs the compiled file as index.js and then we rename it to another file name.

Another addition is the types field. This is important for declaring the existence of the type definitions file.

As with the Babel example, we need to reinstall the package into the test directory. Run these commands:

$ cd ../test
$ rm -rf node_modules
$ npm install ../module-typescript
$ npm test

The test programs should run as before unchanged.

Handling an edge case in Node.js hybrid CJS/ES6 modules

In the Node.js documentation for hybrid CJS/ES6 modules there is a warning about an edge case we didn't discuss earlier. Namely, what happens when an application uses the a hybrid package through both its CJS and ES6 interface? For the most part this isn't a problem, but there is one issue.

Suppose your ES6 code declares a class like:

export class Hello {
    world() { return "Hello World"; }
    async world2() { return "Hello World"; }
}

You dutifully put this into index.mjs and Babel or TypeScript converts that into the equivalent code in the CJS module, index.js. In this case the Hello class will have the same implementation in each. However, in your application what happens with a statement like this:

    if (obj instanceof Hello) { ... }

Technically the implementations in index.js and index.mjs for the Hello class are different. Even if they're the exact same structure, they are different class definitions and are therefore not the same class.

What might happen is for an application to contain both ES6 and CJS modules. We've already seen that results in using either import or require, resulting in different paths being used in the conditional export in package.json, ultimately causing a different file to be loaded. That means in an ES6 module one Hello implementation is used, and in a CJS module another Hello implementation is being used, even though both are coming from the same package.

The cure is the same as the shared.js tactic we used earlier. Let's try it out in module-typescript.

Put the class definition code into a CJS module (shared.js) containing this:

module.exports.Hello = class Hello {
    world() { return "Hello World"; }
    async world2() { return "Hello World async"; }
}

Then in index.ts add this:

export { Hello } from './shared.js';

This demonstrates that re-export works from a CJS module, just as it did with an ES6 module earlier.

After running npm run build inspect the generated code. In both index.js and index.mjs you'll find the Hello class is derived from the declaration in shared.js. That will ensure obj instanceof Hello always correctly identifies the class.

Summary

In this tutorial we've learned how to build a Node.js package that supports both CommonJS and ES6 based usage. To do this we developed a hybrid package containing parallel implementations of the same code. We also learned about an advanced package.json feature letting us explicitly declare the exports from a package.

By learning about this, you've erased one hurdle to fully embracing ES6 modules in your Node.js packages. That's because you now know how to be sure your customers can easily use the package whether they're using ES6 or CJS modules.

About the Author(s)

(davidherron.com) David Herron : David Herron is a writer and software engineer focusing on the wise use of technology. He is especially interested in clean energy technologies like solar power, wind power, and electric cars. David worked for nearly 30 years in Silicon Valley on software ranging from electronic mail systems, to video streaming, to the Java programming language, and has published several books on Node.js programming and electric vehicles.

Books by David Herron

(Sponsored)