Tips for writing ES modules in Node.js

Ant Stanley
11 min readFeb 4, 2019

Recently I’ve been working a bit with ES modules within Node.js. I’ve found documentation and blog posts to be a little patchy with some detail, but not all, and docs all over the place. So this post is essentially everything I’ve learnt about using ES modules within Node.js, transitioning from CommonJS, all in one place.

EcmaScript Modules were defined in the ES2015 specification introduced in June 2015. They were first introduced to Node.js in September 2017 behind the --experimental-modules flag and is due be supported without the flag in 2020. There are a number of benefits of ES modules, namely

  • A universal module system between browsers and Node.js.
  • Static code analysis enabling tree-shaking of ES modules using bundling frameworks like Rollup and Webpack. Ship just the code used in your application.

Node.js will maintain a level of compatibility between CommonJS and ES modules. This post helps to provide useful tips on writing ES modules, for those used to writing CommonJS modules.

There are a number of differences between CommonJS and ES modules that need to be understood. Key differences are

  • ES modules use .mjs files, CommonJS uses .js files. Node.js uses the file extension to determine which module system to load.
  • ES modules are statically imported vs CommonJS modules are dynamically imported.
  • Node.js’s resolution algorithm will import the .mjs version of a module before a .js version, if two versions with the same name exist.
  • You can import modules using a URL path
  • CommonJS brings modules in as a module object. Essentially a normal JavaScript Object you can mutate and basically treat as a normal object. ES modules it doesn’t bring the module in as a normal JavaScript Object, with some limitations on how you interact with an ES module.

Below are some tips for writing ES modules in Node.js if you’re used to CommonJS modules.

Statically importing modules

The import module from 'module' syntax brings in the module globally and read-only. This changes the way you use them in a few ways.

  • All of your import module from 'module' statements must be at the top of your module/script
  • You cannot mix and match CommonJS and ES module syntax in the same module/script. Basically if it’s a .mjs file, make sure there are require() statements in your code.
  • An ES module can import a CommonJS module. Be aware of how the relevant modules export methods and properties and the way they are imported are subtly different in each case. To expand on the example in this post, if you have the following code
//foobar.js
function foo() {
return 'bar';
}
function bar() {
return 'foo';
}
module.exports.foo = foo;
module.exports.bar = bar;

You could reference it in a CommonJS module like this

//app.js
const { foo, bar } = require('foobar');
console.log(foo(), bar());

and the equivalent in ES modules to reference the same CommonJS module, foobar.js, would be

//app.mjs
import { foo, bar } from 'foobar'
console.log(foo(), bar());

Whilst the syntax looks similar, what is happening is slightly different. In the CommonJS example, the line const { foo, bar } = require('foobar') is using Object destructuring to extract the foo and bar objects from require('foobar'). In the ES module example, the import { foo, bar } from 'foobar' syntax is directly importing the named exports foo and bar.

The CommonJS syntax is basically doing this.

const foobar = require('foobar')
const { foo, bar } = foobar
console.log(foo(), bar());

The CommonJS syntax is using two steps to create the foo and bar objects vs the ES module syntax which does it in one step.

Key to understanding how to use CommonJS modules within ES modules is to understand how default and named exports work. To expand on the above example, if you used the following syntax

//app.mjs
import foobar from 'foobar'
const { foo, bar } = foobar
console.log(foo(), bar());

You would get an error. The reason is that the CommonJS module, foobar.js, does not have a default export. To bring the relevant methods in from foobar.js you would either bring them in by name, as the initial working example does, or you would import them under a single namespace like this.

//app.mjs
import * as foobar from 'foobar'
const { foo, bar } = foobar
console.log(foo(), bar());

The * operator references all the methods and properties exported by foobar.js and the as foobar creates a module namespace called foobar that you can use to reference individual methods.

The import module from 'module' syntax can only be used when the module being imported has a default export.

  • You can’t reference any methods, properties or objects of the imported module within the import statement. So code like this
const version = require('./package.json').version

needs to be rewritten like this

import * as pkg from `./package.json`
const version = pkg.version
  • ES modules are read-only, so you can’t mutate them once imported. Syntax like this which works in CommonJS.
const module = require('./module')
const func = () => { return 'this is a function' }
Object.assign(module, func)

versus the ES module equivalent

import module from 'module'
const func = () => { return 'this is a function' }
Object.assign(module, func)

The CommonJS example will work, the ES module example will fail. If you use this pattern you’ll need to figure out another way to achieve what you want.

Exporting Modules

The way you export functions, methods and objects with ES modules changes slightly. The biggest change is the introduction of an explicit default export with the export default syntax.

With ES modules default and named exports can co-exist. For example.

//foobar.mjsfunction foo() {
return 'bar';
}
function bar() {
return 'foo';
}
export { foo }
export default bar

The example above has a named export foo and a default export bar, so to import them you can use the code below

//app.mjs
import { foo } from 'foobar'
import foobar from 'foobar'
console.log(foo()); // 'bar'
console.log(foobar()); // 'foo'

This example imports foo as a module called foo and imports bar as a module called foobar. A more useful example might be where you export a class as the default export, and helper methods as named exports on their own.

If you tried to do this with CommonJS modules and the module.exports syntax, the exports would override each other. For example

//app.js
function foo() {
return 'bar';
}
function bar() {
return 'foo';
}
module.exports.foo = foo;
module.exports = bar;

And the corresponding import with require()

const { foo } = require('foobar'); //will create an error
const foobar = require('foobar')
console.log(foo()); // Error!!
console.log(foobar()); // 'foo'

In the above CommonJS example, in the line module.exports = bar the previous object module.exports.foo is overwritten and isn’t exported so will error if referenced in an import.

Another useful feature of ES modules exports is the ability to export directly from an imported module. For example you can write the following

export { foo } from 'foobar'

Which is the ES module equivalent of the CommonJS code below.

const { foo } = require('foobar')
module.exports.foo = foo

On the whole, ES module exporting is more flexible and explicit.

Working with files in ES modules

The way you reference and work with files in ES modules changes.

The __filename and __dirname statments don’t work in ES modules. For the equivalent operation you need to use import.meta.url syntax. So code that would be written like this in CommonJS

console.log(`Current file is ${__filename}`);
console.log(`Current directory is ${__dirname}`);

Would need to be rewritten like this in ES modules

const currentFile = import.meta.url;
const currentDirectory = new URL(import.meta.url).pathname;
console.log(`Current file is ${currentFile}`);
console.log(`Current directory is ${currentDirectory}`);

The key difference is that import.meta.url brings the filename in a URL format, so using file:// to reference the file. This syntax is currently in stage 3 of the TC39 process and is a JavaScript implementation, not Node.js specific. So code written using this syntax can be used in both the browser and Node.js.

In general you should start using the new Node.js implementation of the URL WHATWG URL API, defined in the Node docs here, for file and path operations. The Node.js Path and File modules still work with ES modules, but you can also use the new URL implementation for working with files. At this point ES module syntax in Node.js only works with URLs using file://, though the spec does support all URLs, so hopefully down the line we’ll be able import modules via a remote URL in Node.

Dynamic Imports with ES modules

Whilst ES modules are statically imported, there is the ability to dynamically import ES modules using the import() syntax. This syntax is also at stage 3 of the TC39 process, and is supported by Node.js behind the --experimental-modules flag.

The import() syntax is function-like, and can be used very similarly to the require() syntax. It dynamically imports ES modules, versus require() which can only import CommonJS modules.

The reality is that you don’t often need to dynamically load JavaScript modules, a common use case is the hot loading of generated code within an application, but in most instances where you use a module, statically importing it is preferable.

The key thing to be aware of with the import() syntax is how to reference the exported methods and objects. If you have a module like below

//foobar.mjs
function foo() {
return 'bar';
}
function bar() {
return 'foo';
}
export { foo, bar };

It has two named exports, foo and bar, which can be imported dynamically like this

//app.mjsconst currDir = new URL(import.meta.url).pathname
const fooBarLocation = new URL(currDir, '/foobar.mjs')
const foobar = import(fooBarLocation) //full path to foobar.mjs file
console.log(foobar.foo(), foobar.bar());

You need to reference the full path to the module you are importing dynamically. The above example uses the new URL implementation to get the current file location and to join that with the relative path of the module, and will work in Node.js and in the browser.

Now lets say you have a module that has a default export, there is a slight difference in the way it is imported, namely default exports will be imported under the .default namespace, so like this.

//foobar.mjs
function foobar() {
return 'foobar';
}
export default foobar;

Dynamically imported like this

const currDir = new URL(import.meta.url).pathname
const fooBarLocation = new URL(currDir, '/foobar.mjs')
const foobar = import(fooBarLocation) //full path to foobar.mjs file
console.log(foobar.default());

When dynamically importing ES modules with default exports just remember that you reference the default export using module.default not module.

It also should be noted, that if you dynamically import ES modules using import() any static code analysis or tree shaking won’t work on those dynamically imported modules.

Package.json and shipping ES modules?

So what happens with package.json with ES modules? The agreed specification is that you still use main to specify your entry point to your ES module. Bundlers like Rollup and Webpack used module to specify a separate entry point before Node support ES modules.

Now under the --experimental-modules flag you can specify an .mjs file under main in package.json or don’t specify a file extension at all and Node.js’s resolution algorithm with look for a .mjs file before a .js file.

This allows you to do a dual export of CommonJS and ES modules for backwards compatibility with older versions of Node.js, or for developers that don’t won’t to use ES modules just yet.

An example package.json that exports both CommonJS and ES modules and uses Babel for transpiling would look like this.

{                         
"name": "dualexport-module",
"version": "1.0.0",
"description": "An example of a module exported with CJS and ESM.",
"license": "MIT",
"main": "index",
"module": "index.mjs",
"engines": {"node": ">= 6.x"},
"scripts": {
"build": "npm run build:cjs && npm run build:mjs",
"build:js": "babel src",
"build:cjs": "npm run build:js -- --env-name cjs --out-dir dist/",
"build:mjs": "npm run build:js -- --env-name mjs --out-dir dist/module && for file in $(find dist/module -name '*.js'); do mv \"$file\" `echo \"$file\" | sed 's/dist\\/module/dist/g; s/.js$/.mjs/g'`; done && rm -rf dist/module", },
"dependencies": {},
"devDependencies": {}
}

In this example main just specifies index without an extension. If you execute this package with --experimental-modules flag it will look for the .mjs version first, if you execute it without the flag, it will look for the .js version and ignore the .mjs version completely.

The package.json also has 4 build scripts

  • "build:js" runs Babel against the src folder
  • "build:cjs" runs the build:js script setting a build environment variable to cjs to tell Babel to export CommonJS modules to dist/
  • "build:mjs runs the build:js script setting a build environment variable to mjs to tell Babel to export ES modules for dist/module then runs the find command to file all .js files in dist/module and then moves them to dist whilst renaming them from .js to .mjs

This uses Babel to do the transformation source code written using ES modules, and using the .mjs extension to a CommonJS and ES module version that can be consumed by either module system.

The .babelrc.js configuration that goes along with this package.json looks like this

module.exports = { 
presets: [
['@babel/preset-env', { targets: ['node 6'] }],
],
plugins: [
['@babel/plugin-transform-classes', { loose: true }],
['@babel/plugin-transform-destructuring', { loose: true }],
['@babel/plugin-transform-spread', { loose: true }],
['@babel/plugin-syntax-dynamic-import'],
['@babel/plugin-syntax-import-meta']
],
env: {
cjs: {
presets: [
['@babel/preset-env', { modules: 'commonjs' }],
],
},
mjs: {
presets: [
['@babel/preset-env', { modules: false }],
],
},
},
};

The Babel plugins ['@babel/plugin-syntax-dynamic-import'] and ['@babel/plugin-syntax-import-meta'] have been added specifically to handle the new ES module syntax for import.meta.url and import(). The problem is that these problems allow parsing of the import.meta.url and import() but they don’t transform them, so will bring ES syntax into you CommonJS module, that will fail. The workaround to do this is to handcraft an ES module and CommonJS version of the module using the relevant allowed syntax, and in the CommonJS transpilation config ignore ES module version when transpiling. Extending the above config it would look like this

module.exports = { 
presets: [
['@babel/preset-env', { targets: ['node 6'] }],
],
plugins: [
['@babel/plugin-transform-classes', { loose: true }],
['@babel/plugin-transform-destructuring', { loose: true }],
['@babel/plugin-transform-spread', { loose: true }],
['@babel/plugin-syntax-dynamic-import'],
['@babel/plugin-syntax-import-meta']
],
env: {
cjs: {
presets: [
['@babel/preset-env', { modules: 'commonjs' }],
],
ignore: [
'src/functionWithImportMeta.mjs',
'src/functionWithDynamicImport.mjs',
]
},
mjs: {
presets: [
['@babel/preset-env', { modules: false }],
],
},
},
};

This Babel config also assumes that you have written your code using .mjs modules and the transpile configuration triggered by the esm environment variable does no transformation of module syntax.

The package.json keeps the module as a separate entry point for backwards compatibility reasons, though it’s not strictly needed as Rollup can resolve .mjs files with the correct configuration of rollup-plugin-node-resolve

This package.json and .babelrc.js is based on the configuration used for the graphql npm module, which from version 14 onwards, is written using ES modules with the .mjs extension and ships both .js and .mjs versions of the module.

The huge advantage this gives anyone using graphql with a bundler like Rollup is that you can distribute a module with the core GraphQL functionality for a GraphQL server at around 350KB vs the approximately 2.4MB of the entire module with both .mjs and .js versions.

ES modules for CLI’s

This is very platform dependent, but the general advice is wait for the --experimental-modules flag to be removed before using ES modules in a CLI.

If you’re using MacOS and you create an executable file in ./bin and reference it properly in package.json and use the #!/usr/bin/env node--experimental-modules shebang for that file, it will work. But it will only work on MacOS, it will not work on any other Linux based operating system. Pain has taught me this.

The reason is that on Linux systems it will only take one parameter in the shebang, the bit #/usr/bin/env in it, and it won’t read the --experimental-modules flag as that’s a second parameter to the shebang, as node is the first parameter.

So unless you’re shipping a CLI for MacOS only, make sure you transpile down to CommonJS code.

Useful links to read

Hopefully you find this useful, and are saved from some of the pain I went through! Getting on top of ES modules now will give you a head start before the --experimental-modules flag is removed, and help with implementation of the specification as potential bugs can be found before the flag is removed.

Ant Stanley

--

--