From CommonJS to ES Modules: How to modernize your Node.js app
I recently ported a midsized, vanilla Node.js app from CommonJS to ES Modules. Here’s what I learned and how you can modernize your project step by step.
What’s the difference between CommonJS and ES Modules?
While CommonJS is de facto everywhere in the Node.js universe, it’s safe to say that ES Modules are the future. They’re now the official standard format to package JavaScript code for reuse. No matter if in the browser or in Node.js, both systems allow you to import and export code from different files, making it possible to split your project into junks of self-contained functionality.
// CJS
const utils = require('../utils')
const funcA = () => {}
const funcB = () => {}
module.exports = { funcA, funcB }// ESM
import { utilA } from '../utils/index.js'
export const funcA = () => {}
export const funcB = () => {}
You might already use ES Modules in the browser (probably with the help of webpack or a module bundler of your choice). I therefore won’t go into further details. Mahdhi Rezvi has a great blog post explaining the differences of both systems in depth if you aren’t familiar with them. What matters for this guide is that the current version of Node.js has a solid support for ES Modules. The feature isn’t hidden behind a flag anymore and the official docs are marking ECMAScript modules as “stable”.
It’s time to bring your app into the future of JS development 🚀
Tips along the way
- Most changes are small, but there’re a lot of them. It will take a while to adjust all exports and imports and your code won’t run in the meantime. You shouldn’t start this task at the end of the day.
- I recommend to use prettier and eslint. It’s optional, but it will make your life a lot easier. We need to modify every single file and those tools help us to keep the formatting consistent, while also checking errors we overlooked. Especially the eslint plugin “eslint-plugin-import” has rules that are relevant for us. Rules that verify our imports. Make sure to configure at least import/no-unresolved, import/no-commonjs and import/extensions.
{
...
"plugins": ["import"],
"rules": {
"import/no-unresolved": 2,
"import/no-commonjs": 2,
"import/extensions": [2, "ignorePackages"]
}
}
Step by step: From CommonJS to ES Modules
1) Prepare your code
Everything is easier when you and your code are prepared.
- Check that your Git working copy is empty, start a new branch, update your dependencies and ensure that your tests are succeeding.
- Install the latest Node.js version (at least the current LTS) and verify that your app is running smoothly after the update.
Commit all changes, brew yourself a coffee and take a deep breath. It’s showtime🧑💻
2) Update the package.json
Tell Node.js that all files are ES Modules by adding "type": "module"
to the package.json
. You can alternatively use the .mjs
file extension for all your files, but I prefer the former.
{
"name": example",
"version": "1.0.0",
"type": "module",
...
}
3) Convert all imports and exports
You can do this by hand when your project only has a few files, but it’s a painful process for any midsized Node.js app.
cjs-to-es6 is a CLI that converts JavaScript files from CommonJS to ES6 Modules. It’s unmaintained, but still the best tool I could find. It successfully ported around 80% of my files. That’s fine. Both modules systems are different and not everything can be converted 1:1.
Run the tool with the verbose flag to see which files failed: cjs-to-es6 --verbose src/
.
4) Fix imports and exports
As already mentioned—Both modules systems are different. It’s impossible to convert all files automatically. While cjs-to-es6 is a big help, it’s still necessary to look at each file individually. Here’re a few issues I had and how I fixed them:
File extensions
cjs-to-es6 converts const package = require('package')
to import package from 'package'
and const localFile = require('../utils/localFile')
to import localFile from '../utils/localFile'
. That’s fine. At least for the external package.
Local file imports require a file extension. Every import must end with .js
(or .mjs
depending on your choice). You might haven’t seen this before, because it’s not necessary in the browser (when using a module bundler), but this is how ES Modules work. File imports are URLs.
Do this:
import something from './something.js'
Don’t do this:
import something from './something'
Directory indexes
Directory indexes must be fully specified. Importing ../utils
won’t magically import the index.js
anymore.
Do this:
import * as utils from '../utils/index.js'
Don’t do this:
import * as utils from '../utils'
Object exports
It was common to export an object of functions when a file had multiple functions to expose. You can still export objects, but this isn’t the correct way anymore. Instead, add export
in front of every function or variable you want to export. This has the advantage that you can now import them individually via import { funcA, funcB } from '../utils/index.js'
or all at once via import * as utils from '../utils/index.js'
.
Do this:
export const funcA = () => {}
export const funcB = () => {}
Don’t do this:
const funcA = () => {}
const funcB = () => {}export default {
funcA,
funcB
}
Broken imports of external modules
You might need to adjust the import of some external npm packages depending on how they export their code. Checking the GitHub repo for existing issues usually does the job. Sometimes it can also help to try a few common ESM imports to see where the code you’re looking for is hidden behind.
In my case it only got confusing when the module exported an object (see “Object exports”). In this case you can’t import a property or function directly via import { funcA } from 'package'
. You instead need to import the object and access the function you’re looking for later on.
This works when the export is an object:
import signale from 'signale'const instance = new signale.Signale()
This won’t:
import { Signale } from 'signale'
import * as signale from 'signale'
JSON imports
This made me a bit sad. It’s currently not possible to import JSON files, but there’re three ways to get around this problem:
- Use
fs
to read the content of a JSON file and parse it usingJSON.parse
- Use JS modules instead of JSON and export an object
- Use the experimental
--experimental-json-modules
Node.js flag
ESLint
I had to rename my eslintrc.js
to eslintrc.cjs
, because ESLint doesn’t support ES Modules, yet. Using a JSON instead of a JS configuration also does the job.
Note that we can’t support .mjs configuration files yet due to ESLint currently being synchronous in nature. — kaicataldo
NYC Code Coverage
Are you using nyc
(the Istanbul command line interface that reports the code covered by your tests)? It’s not ready for the future, yet. The good thing is that V8 (the JavaScript engine behind Node.js) has code coverage built-in. You can take advantage of it using c8. A CLI similar to nyc. And because it relies on the build-in V8 code coverage it always works with the newest syntax supported by Node.js.
Miscellaneous
I’m sure that you will run into specific issues not covered by this guide. Every code is different. Make sure to take a look at the Node.js documentation before searching the web. It’s up-to-date and contains everything you need to know on one page.
5) Verify your changes
It’s time to check if everything went well. Use prettier to clean up your code and let eslint check your imports (this is where eslint-plugin-import is a big help). Run your app to see if there’re obvious errors. I’m sure that your app won’t launch on the first try. There’s always a file that has been overlooked 🙄
Congratulation
You successfully ported your Node.js app to ES Modules 👏 I hope this article made the process a bit easier. Let me know what you think and what issues bothered you during the conversion!