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 theimport()
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:
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
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
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.