Practical guide: Node.js 12 supports ES modules. Are your apps ready?
Tags: javascript, node, es2015, modules
Published: 2019-07-23 14:52:09

_**Updated on May 2020**. By October 2020 Node.js 14 will be released. Some things have changed since the time I've written this article (July 2019)_

If you are a Node.js developer either by writing Node.js apps or libraries, you probably know that Node.js 12 supports ECMAScript standard modules!

- To enable ESM in Node.js 12, you still need the `--experimental-modules` flag. Read more in the [official docs](https://nodejs.org/docs/latest-v12.x/api/esm.html#esm_enabling)
- Node.js 14 is **probably** going to be released with support for ESM without the flag. Read more in the [official docs](https://nodejs.org/docs/latest-v14.x/api/esm.html)

---

The question is… Are your apps and libraries ready?

This post explores the way of writing modular apps and libraries for Node.js 12 *without using any build* process. The code *written* is exactly the code *executed*.

⚠️ **Caution!** This post is written with the "consensus" in May 2020 and  the *current* behavior of Node.js (Node.js 12 with `--experimental-modules` flag and Node.js 14 without it). It is *likely* but not guaranteed that 14 will behave the same once it reaches LTS in October. Whenever possible, references to official 📝 Node.js documents will be included so you can read them and take your own decisions.

> 🧪 Things that are currently experimental but likely to remain as they are when once Node.js reaches LTS are marked with this symbol. Read them to be an early adopter of ES modules almost from the first day but don't use it in production before the LTS release.

> 💥 Things marked with this symbol cause breaking changes. This is specially important for library authors. They should not be used in production before the LTS release.

> 🛑 Things marked with this symbol are more dangerous to do, even for early adopters as they might change in the future.


# The transition phase. Plan it!

Ideally Node.js projects will gradually adopt ES modules and stop using CommonJS. This is possible from Node.js 12 and it depends on developers adoption grade (and the ones of your external dependencies).

In Node.js 12 and 14, CommonJS is still the default option but ES modules would be the preferred one in future versions.

Take your time to plan the transition. Otherwise you might have two or three versions of the same project to maintain at the same time.

What can you do?

## Summary

This is a 4-level transition roadmap example:

|            | No adoption  | Soft adoption | Hard adoption | Full adoption |
|------------|--------------|---------------|---------------|---------------|
|            |   | 🧪 | 💥 | 🛑 |
| CommonJS   | default      | default       | deprecated    | dropped       |
| ES modules | disabled     | enabled       | default       | default       |

No adoption and full adoption levels are the equivalent of using either CommonJS only or ES modules only and needs no more explanation.

Let's dig into soft and hard adoption levels

## Soft adoption. Enable ES modules, keep CommonJS as default

Goals:
 
- Write your own code with ESM syntax.
- Offer a ESM API.
- Start deprecating the CJS API.
- Do not do any breaking changes.
- Be ready to drop CJS.

Since this is all about dependencies, in case that some some files are in ESM and some in CJS, you will face:

1. `index.js` (migrated to ESM) depends on `module.js` (in CJS)
2. `index.js` (in CJS) depends on `module.js` (migrated to ESM)

### `index.js` (migrated to ESM) depends on `module.js` (in CJS)

In this case, we are migrating first `index.js` keeping the dependencies in CommonJS.

Rename `index.js` to `index.mjs` to enable ESM on that file and disable CJS. Now, `require` doesn't work anymore in this file.

If some of your dependencies are still in CommonJS, make sure to understand the interoperability between those styles.

### `index.js` (in CJS) depends on `module.js` (migrated to ESM)

> ❓🧪 Use this depending on your trust on `esm` npm package or your confidence on the `import()` operator

Make sure that your dependencies have `.esm` extension. Use both named and default exports accordingly.

You need to understand the interoperability between those styles. In particular, you need to understand the [npm package `esm`](https://www.npmjs.com/package/esm) or the [`import()` operator](https://2ality.com/2017/01/import-operator.html)

### As library author

> 🧪 These changes are not breaking. However, since ES modules are still in experimental phase, it is recommended that you perform a **preminor** or **prepatch** version or any other "beta" upgrade: run `npm version preminor`, `npm version prepatch` or similar. Publishing a stable release of a library forcing experimental features to be enabled to the users is not a good idea.

Goals as library author:
 
- Offer two entry points: CommonJS module and ES module.
- Keep the CommonJS entry point as the main one.
- Recommend the `mjs` alternative. 🧪

If your entire library is migrated, your ESM entry file will be something like `index.mjs`:

```javascript
// my-lib/index.mjs
export default function sayHello (name) {
  return `Hello from my-lib/esm: ${name}`
}
```
  
Create a `index.cjs`. Once you change your library to support `esm` by default, this will be the entry point for the legacy code

```javascript
// my-lib/index.cjs
require = require('esm')(module)
module.exports = require('./index.mjs')
```

Deliver both `index.mjs` and `index.cjs` as part of your npm package (edit your package.json). Serve `index.cjs` as the "main" entry

```javascript
{
  "main": "index.cjs",
  "files": [
    ...,
    "index.mjs",
    "index.cjs"
  ],
}
```

> 📝 Node.js may adopt a field name in `package.json` specifically for ES modules. In that case, you could include both "name" and that new field as well. [Follow the discussion](https://github.com/nodejs/modules/issues/273)

In this stage, library users should **explicitly** access `index.mjs` if they want to use the ESM version via an explicit path.

```javascript
import sayLibrary from 'my-lib/index.mjs'
console.log(sayLibrary('World'))
```

> 📝 Read more in the [Node.js help](https://nodejs.org/api/esm.html#esm_package_entry_points)

#### Alternative. Keep the `index.js` file (main entry) as a deprecated copy of `index.cjs`

Offer three entry points keeping `index.js` as the main one.

```javascript
{
  "main": "index.js",
  "files": [
    "index.js",
    "index.mjs",
    "index.cjs"
  ],
}
```

Deprecate `index.js` in order to encourage CommonJS users to require the `index.cjs` file explicitly.


```javascript
// my-lib/index.js
require = require('esm')(module)
process.emitWarning('This library will be a ES module in the next major version. If you still need to use the CommonJS version, require("my-library/index.cjs") instead')
module.exports = require('../index.mjs')
```

The library can also recommend to use the ES module version. 

> 🧪 "Recommending" an experimental solution from a stable release is not a good idea. Remember to publish your library as an unstable release (like pre-release, alpha, etc.)

The library is required/imported like this:

|                             | Before | Now            | Next version |
|:----------------------------|:-------|:---------------|:-------------|
| `require(my-lib)`           | OK     | **deprecated** | dropped      |
| `require(my-lib/index.cjs)` | N/A    | **legacy**     | deprecated   |
| `import my-lib/index.mjs`   | N/A    | **🧪 OK**      | OK           |
| `import my-lib`             | N/A    | **N/A**        | recommended  |

#### Extra. Libraries with more than one file

If you offer multiple endpoints from your library like this:

```javascript
const f1 = require('my-library/function1')
const f2 = require('my-library/function2')
```
 
📝 You might want to read [the Package Exports Proposal (still in "pre-PR" status)](https://github.com/jkrems/proposal-pkg-exports)


### Additional notes of this phase

- New code should be written with ES modules directly.
- If you are writing a library, be sure that you are using the correct standard
- If you are writing a library, make sure to offer a CommonJS alternative and make it the **default one**, even if you want to deprecate it. Make sure to test it against `Node.js < 12`

## Transition 2. Enable ESM as default. Support CommonJS

> 🛑 Do not do this in production until ESM is supported without experimental flags.

Goals:

- Work with ES modules by default
- Still support *legacy* CommonJS but deprecate it
- Drop deprecated code

### Make the breaking change!

In the `package.json` file of your project add:

```javascript
{
  "type": "module"
}
```

Now all the `.js` files are treated as `.mjs`. You cannot use `require` anymore in any `.js` or `.mjs` file.
   
Both `.js` and `.mjs` extensions works seamlessly.

> You could use both to differ between scripts vs modules… But that's only an opinion. For example, you can keep an `index.js` file but use `.mjs` as extension for its dependencies.

Rename **all** the `.js` files that still use CommonJS to `.cjs`. Fix the imports/exports paths

Extra. Deprecate the remaining `.cjs` files (you should drop them soon!).

### Library author. Make the breaking change!

> 💥 **Every** step in this section causes breaking changes. If you are a library author, you might want to do a **single major release** with all the changes
> 
> 🧪 Also every step in this section in experimental status.
> 
> Recommendation: do a major + non-stable release: `npm version premajor`.


⚠️⚠️⚠️ In the `package.json` file of your project add "type" (💥 changing "type" to "module" is also a breaking change) and point to `index.mjs` file as the entry of the library:

```javascript
{
  "type": "module",
  "main": "index.mjs" ⚠️⚠️⚠️
}
```

⚠️⚠️⚠️ It is **not guaranteed** that the field will be called "main".

> 📝 Actually [the ofificial announcement](https://github.com/nodejs/modules/blob/master/doc/announcement.md#es-module-code-in-packages) says:
>
> «While we are aware that the community has embraced the "module" field, it is unlikely that Node.js will adopt that field […] Please do not publish any ES module packages intended for use by Node.js until this is resolved.»
>
> 📝 However, the [roadmap documentation](https://github.com/nodejs/modules/blob/master/doc/plan-for-new-modules-implementation.md) says:
> 
> «Status quo is current `--experimental-modules` behavior: `"main"` points to exactly one file, and all file extensions are mandatory (by default), so there is no possibility of an `import` specifier pointing to different files in ESM versus CommonJS.»
> 
> «Status quo has consensus and will ship absent any other proposals achieving consensus. “New field” proposal lacks consensus.»

After you have set "type" to "module", using bare-paths with CommonJS (`require(my-lib)`) no longer works (💥 Changing the way to reach your API is a breaking change)


Once both `index.mjs` and `index.cjs` are reachable, you can delete the `index.js` file. Additionally, you can add a deprecation warning in `index.cjs` if you are planning to drop CommonJS support.

```javascript
require = require('esm')(module)
process.emitWarning('CommonJS support will end in the next major version of this library')
module.exports = require('../index.mjs')
```

You can also mark `esm` (the library that we use only for legacy support) as "optional dependency" of your library. Users that uses ES modules do not need to install the library. 💥 **Converting a dependency to optional is always a breaking change**


|                             | Now            | Next        |
|:----------------------------|:---------------|:------------|
| `require(my-lib)`           | **dropped💥**    | dropped     |
| `require(my-lib/index.cjs)` | **deprecated** | dropped     |
| `import my-lib/index.mjs`   | **OK** 🧪         | OK          |
| `import my-lib`             | **OK** 🧪         | OK          |

---

# That's it!

In future posts I will probably mention something about authoring other types of libraries: libraries for/written in TypeScript, libraries for frontend javascript, isomorphic libraries… Who knows!

Also I want to discuss about the consequences of adopting ES modules: code completion from text editors, follow the standards, etc.

# Further reading:

- ["Modules" chapter of the book *Exploring JS*](https://exploringjs.com/impatient-js/ch_modules.html), for more information about differences between CommonJS modules and ES modules like dynamic export/import
- [ECMAScript modules](https://nodejs.org/api/esm.html), from Node.js official docs
- [This blog post](https://2ality.com/2019/04/nodejs-esm-impl.html) from the 2ality blog.
- [The Node.js official announcement](https://medium.com/@nodejs/announcing-a-new-experimental-modules-1be8d2d6c2ff)
- [Plans for New Modules implementation](https://github.com/nodejs/modules/blob/master/doc/plan-for-new-modules-implementation.md)