loading...

Practical guide: Node.js 12 supports ES modules. Are your apps ready?

exacs profile image Carlos Saito Updated on ・8 min read

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
  • Node.js 14 is probably going to be released with support for ESM without the flag. Read more in the official docs

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 or the import() operator

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:

// 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

// 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

{
  "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

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

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

📝 Read more in the Node.js help

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.

{
  "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.

// 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:

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)

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:

{
  "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:

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

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

📝 Actually the ofificial announcement 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 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.

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:

Posted on by:

exacs profile

Carlos Saito

@exacs

Developer. I like JavaScript (specially since ES2015) and Elixir. I don't like python. I don't know why, maybe because I haven't tried it enough.

Discussion

markdown guide