Most major languages have its module system, but Javascript lacked a standard one for many years. The environments where Javascript is used are different in terms of module loading. The browser requests them asynchronously, in Node.js it is synchronous. This why two different ways emerged in them: Asynchronous Module Definition in the browser and CommonJS in Node.js.

With the introduction of the ES2015 standard a unified way was introduced to handle modules in every Javascript environment. ES modules have quickly became available in the browser through transpilers (like Babel, Typescript), but in Node.js it was a bit harder to come up with a solution that is backward compatible and enables incremental upgrades. In Node.js native ES modules are available behind the experimental-modules flag for a long time. With version 12 it will be released as a stable implementation when it reaches the LTS phase in 2019 October. It is time to explore the differences between the two module systems and plan our upgrade strategy. In this article we will examine how they work and outline a working upgrade strategy.

Similarities and differences

We will go through the similarities and differences between the CommonJS module format and the ES module format.

File extension

By default CommonJS module files have an extension of .js or .cjs and ES module files have the .mjs extension. We can only alter the module behaviour of .js files.

Add “type”: “module” to package.json and Node.js will treat all .js files in your project as ES modules. Otherwise it will treat them as CommonJS (“type”: “commonjs” is the default). This behaviour can be altered in every subfolder, making the nearest package.json the strongest to determine the module format.

Syntax

The syntaxes of the two module formats differ, but everything has its equivalent in the new format.

In CommonJS we export with module.exports. For named exports we assign the variable as a property, for default exports we assign to module.exports directly. There is no difference in exporting a class than a variable of any type.

We have to use destructuring for named exports on the return value of the require function.

In ES modules we use the export keyword instead of the module.exports variable and the import keyword instead of require. When exporting classes we don’t have to assign it to a variable, just use the export keyword before the class definition.

It is important to note that the two syntaxes and module systems cannot be mixed within the same file. If we did so, we would get a compiler error. It means that we cannot write a top level import inside a CommonJS file nor use the require function in an ES module file.

Bindings

To demonstrate how bindings work, I implemented a little counter that exports its value through a variable. When we increase that value through the exported function, we would expect that the value increases, but it doesn’t.

The reason behind this is that in CommonJS exports are exported as values not as references. So the exported counter has no relationship with the variable inside the module.

Within ES modules exports are assigned by reference, this is what we call a binding. Here the counter is the same variable reference as the one within the module. It is not surprising that the value of counter has increased to one after incrementing it.

Execution order

In CommonJS modules the execution order is the same as we would expect. In the example below the logs are displayed before and after the module is loaded. It happens this way because require is a function and at the time of execution it simply reads the file synchronously and executes it.

The require function can even be be overwritten/modified to extend the original behaviour.

ES modules act different as we would expect. The root cause is that import is not a function, but a language keyword. With language keywords the compiler can traverse and parse the whole application tree before executing it. Only after traversing does the compiler start executing them from bottom to top.

Loading from the top and executing from the bottom enables the compiler to make the module loading process parallel. It is not yet supported in Node.js, but the ES standard gives the opportunity for it.

This mechanism results in the console statement to be executed first, followed by the logs of the index file.

Dynamic imports

Dynamic imports in CommonJS modules are straightforward as they are the same as requiring from the top of the file.

ES modules are a bit trickier as we cannot use the same syntax as before. Import statements can only be used at top level and cannot be placed inside a function.

If we want to import dynamically, we have to call the import as a function (it still remains a keyword) and it returns the exported variables as a Promise. Promises are needed to be compatible with other environments where files can be accessed asynchronously (for example browsers).

Interoperability

We looked through how things work and differ, now we can look at how it is possible to interact within the two module systems. Of course we don’t need interoperability if we make one big rewrite and go through all the files and convert them. For big applications I wouldn’t recommend it as things can break and sometimes it cannot be done in a reasonable amount of time. For small applications it can work.

For big applications it is safer to go with the incremental upgrade where both systems co-exist.

CommonJS modules

From CommonJS files we can require other CommonJS files as before, but ES modules can only be accessed via dynamic imports. We can’t use the import statement at the top of the files and just replace require statements.

The hard thing here is that when we replace require with dynamic import, the module loading becomes asynchronous from synchronous. It can cause a lot of modification of the code as we have to handle the returned Promise (await every import).

ES modules

From ES modules we can import other ES modules and CommonJS modules also. When importing CommonJS modules we have to use the default import and add one extra destructuring statement to access the properties of the default export. The default import keeps the synchronous flow within the application, no need to handle Promises.

Incremental upgrade

When doing an incremental upgrade there are two ways to consider.

The first one is to go from bottom to top and dynamically import ES modules from CommonJS modules. The biggest drawback here is that we have to alter every file’s control flow and adjust it to be able to handle asynchronous imports instead of synchronous ones.

The second one that worked for us is to go from top to bottom. Start with the entry script, convert it to ES module and use default imports down the chain of files. When the file imported is not CommonJS anymore we can convert the default import to named import. This way the synchronous code remains synchronous as before.

We took the following steps to convert the whole application to ES modules:

Upgrade the application to Node.js 12

Enable ES modules with the experimental-modules flag

Convert the entry file to ES module: rename it to .mjs and use default import for the files it required before

Repeat the previous step on the files imported from the entry file

Change the default import between the already imported ones to named imports

Do these steps until every file is in the new syntax and has an extension of .mjs

Add “type”: “module” to package.json: it turns .js files to ES module syntax

Replace the extension of files back to .js

Libraries

If we want to convert a library to ES modules we have to take into consideration that for a long time CommonJS applications will still be present. The library must support both formats to maintain backward compatibility.

Luckily when importing a file, there is a precedence in choosing the file to import. When coming from an ES module, first it checks for a file ending in .mjs and then checks for .js . If we publish the ES module version of the library in the .mjs files and publish the CommonJS version in .js files, both type of applications will be able to use the library as CommonJS applications will only require the .js files. To make it work we have to leave the extension from the main field in package.json.

The upgrade steps are similar:

rename files to .mjs and convert them to ES module syntax

remove extension from main field in package.json

use a transpiler like Babel to convert ES modules into CommonJS modules placed into .js files next to .mjs files

publish both the .mjs and .js files

at import time the engine will decide wether to import the .mjs file or require the .js file.

Summary

It is a great thing that Javascript finally has a standard module system and it will be enabled by default. Before upgrading, it is advised to understand the differences between the new and the old module system. When doing the upgrade we can do it incrementally, because interoperability exists between them. The same upgrade strategy can be used for applications and libraries, all of them can be moved to the new one.

If you want to hear more about the topic or just prefer listening to reading, I would suggest to check out the talk of Gil Tayar about ES modules

I wish you a happy upgrading to the new module syntax 🚀🚀🚀!