Avoid these issues when using new ECMAScript modules in your Node.js application

Photo by Cookie the Pom on Unsplash

ECMAScript modules are the official standard format to package JavaScript code for reuse in the future. Es6 modules now have full support in Node.js 12 and above so it's time to start using them.

JavaScript developers and node libraries have typically used commonjs for modules up to now. If you've used typescript in the past few years you will be familiar with the module import syntax in you application. Instead of commonjs require("module") most typescript applications use some variation of import module from "module".

Typescript will then transpile this import syntax into commonjs require statements for you. This step is not necessary in modern Node.js applications. You can just use import module from "module" directly in your transpiled code.

If you use typescript you can change just change your tsconfig settings to output ECMAScript es6 modules and you will be good to go. If you don't use typescript you might have to do some rewriting if you want to get your app updated.

Here are solutions to the issues that took me a bit of time and investigation to figure out when I was upgrading my Node.js application to use ECMAScript modules like configuring typescript, setting up jest, configuring the package.json correctly and more.

Node.js support for ECMAScript es6 modules

The support for ECMAScript modules is stable as of Node.js 14. So there are no issues using this functionality.

If you still use Node.js 12 in production (I'm guilty of this!) then the ECMAScript modules feature is marked as Experimental so you should use some caution. But the support is fully there. Please note that Node.js 12 is end-of-life for support from 2022-04-30 so you should be considering upgrading to Node.js 14 anyway.

If you provide a library that other applications depend on of course it is worth being mindful of the Node.js versions your customers are supporting.

In general, most actively developed Node.js apps as of 2021 should support ECMAScript modules natively.

The package.json type property

There are two ways primary ways to use ECMAScript modules in Node.js. You can use the .mjs suffix on your files or you can set the type: "module" property in your package.json. The mjs suffix isn't really relevant or practical when using typescript so it's easier just to set the type property in your package.json file.

Consider the example package.json file type below and note that I have explicitly set type to module.

  "name": "shared-api-client",
  "version": "1.0.0",
  "description": "OpenAPI client for shared-api-client",
  "author": "OpenAPI-Generator",
  "main": "./dist/index.js",
  "typings": "./dist/index.d.ts",
  "type": "module",

This is extremely important because it tells consumers of your package to load modules from your code as ECMAScript modules, not commonjs modules.

If you observe an issue with your published module where a tool can't import modules from it correctly then you probably missed setting the type property and other Node.js tooling will assume you expect the modules to be loaded via commonjs. They will break.

For example you can have Jest use es6 modules natively if you have configured experimental modules.

But if your package uses import/export and you don't tell Jest that the package is using es6 modules then it will try to load it as commonjs and Jest will break. You will get an error: Jest “SyntaxError: Unexpected token export”.

Always remember to set the type: "module" if you're publishing a package with ECMAScript es6 Modules.

Calling an ECMAScript module using Node.js

If you try to call your new package using Node.js node package/main.js it will fail with an error ERR_MODULE_NOT_FOUND.

At the moment you need to tell node to use node module resolution.

node --es-module-specifier-resolution=node main.js

Or you have to manually write your imports to import the file like this

// Do imports this way if you don't want to specify --es-module-specifier-resolution=node (note the .js)
import mod from "./myModule/mod.js";

// If you want to use this import method you must specify --es-module-specifier-resolution=node!
import mod from "./myModule/mod";

Using the top level await (in typescript)

An await is typically called in an async function. There is no way to have one outside of a function. Like this...

import fs from "fs/promises";
// this is ok because it's in an async function
const myFunc = async () => {
  await fs.readFile("path");
};

// this fails to compile in tsc because it is at the top level of a module
await fs.readFile("path");

// just to make this a module
export {};

But there are real use cases for having awaits that are not in a function.

In particular if you are setting up resources for jest tests you might have a setup file that jest runs before it starts running tests.

import dotenv from "dotenv";

import { AuthenticatedRequests } from "./commonDataModels/AuthenticatedRequests";
dotenv.config();

// async function that gets a valid auth token from a third party service so we can build requests
await AuthenticatedRequests.setToken();

export {};

You could avoid the await there by using .then() promise syntax in the setToken() method and make it a hanging async method where you don't wait for the result and hope it finishes on time. But I much prefer using async await where I can!

If you're writing a native node module with a .mjs file the top level await should just work for you.

If you're writing this in typescript then you will have to set the module option in tsconfig to "esnext" (as of writing this). I describe how to configure typescript in another section.

Importing commonjs modules into ECMAScript es6 modules

Now that you're targeting es6 or higher you can't require() any commonjs modules in your own modules anymore. You have to import them using import syntax.

Both typescript and Node.js provide interoperability for doing this. I'll describe the typescript method.

Most typescript applications importing commonjs modules should turn on esModuleInterop in their tsconfig file. Then you can just use a "normal" import.

The old typescript commonjs interop handled commonjs imports in ways that broke es6 standards. EsModuleInterop performs some changes to the typescript compiler to better handle these issues. These issues are described in the typescript documentation here.

// this imports the default export from a commonjs module.
import dotenv from "dotenv";

// this imports default and any named exports on module.exports
import * as dotenv from "dotenv";
// you could access dotenv.default here
// or const postConfig = dotenv() (dotenv module doesn't export a function on exports but this is just an example)

The variables __filename and __dirname are not available with ECMAScript es6 modules

When you try to use one of these special variables you will get an error that "ReferenceError: __filename is not defined" if you use ECMAScript Modules.

This is because they are simply not available when Node.js is running in ECMAScript es6 module mode. There is an alternative method for getting the current working directory available to you in import.meta.. Here's how to use it.

console.log(import.meta.url);
// returns where the module (usually the file) is located e.g. file:///Users/me/personal-projects/blog/e2e-backend/src/preRun.ts

// and how to get a string file path
console.log(new URL("./new-file-path.json", import.meta.url).pathname);
// returns e.g. /Users/me/personal-projects/blog/e2e-backend/src/new-file-path.json

Node.js documentation suggests that you can supply fs method with a URL instance directly but the typings I used in my application required a string to be passed. So that's why I pass the .pathname property of the URL to fs methods.

I suspect this typings issue will be fixed in newer versions of Node.js types so you might be able to pass the URL without reading the pathname in your app.

// this works on my application with installed Node.js types
const contents = fs.readFileSync(
  new URL("./new-file-path.json", import.meta.url).pathname
);

// this is how the Node.js docs suggest using URL with fs methods but this did not
// pass with my typescript Node.js types
const contents = fs.readFileSync(
  new URL("./new-file-path.json", import.meta.url)
);

Configuring typescript for ECMAScript es6 Modules

You will need to set your typescript configuration to support es6 module features. I'm going to assume you're using typescript 4 or higher.

If you're using Node 14 and above you can access all the features available on es2020 no problem. You can use those libs and you can target them for output also.

If you just want to use ECMAScript es6 Modules and you don't need to use a top level await then you can use es2020 module. Like this

{
  "compilerOptions": {
    "lib": ["es2020"],
    "module": "es2020",
    "target": "es2020",
    "strict": true,
    "esModuleInterop": true,
    "skipLibCheck": true,
    "allowSyntheticDefaultImports": true,
    "forceConsistentCasingInFileNames": true
  }
}

If you also want to use a top level await then as of writing this article you will need to set the module option to esnext like this.

esnext is designed to contain experimental features so you might not want to use it in production.

Top level awaits will likely be added to a permanent module configuration in the future so if you're reading in the future please check the typescript documentation for support of top level await!

{
  "compilerOptions": {
    "lib": ["es2020"],
    "module": "esnext",
    "target": "es2020",
    "strict": true,
    "esModuleInterop": true,
    "skipLibCheck": true,
    "allowSyntheticDefaultImports": true,
    "forceConsistentCasingInFileNames": true
  }
}

My personal opinion is that at the current time of writing top level awaits are a nice to have but there are usually ways around requiring them in production runtime environments. I do use them in development tooling that is run every day though.

If you're on Node.js 12 this is the typescript configuration you should be using

{
  "compilerOptions": {
    "lib": ["es2019", "es2020.promise", "es2020.bigint", "es2020.string"],
    "module": "esnext",
    "target": "es2019",
    "strict": true,
    "esModuleInterop": true,
    "skipLibCheck": true,
    "allowSyntheticDefaultImports": true,
    "forceConsistentCasingInFileNames": true
  }
}

It's important to note that the import.meta property you need to replace __filename with is only available in es2020 module or higher ("esnext" would also have it).

Configuring Jest and typescript for ECMAScript es6 Modules

If you want to use es6 modules in jest with typescript I recommend using the ts-jest preset and turning on useEsm.

npm i --save-dev ts-jest
// or
// yarn add -D ts-jest
{
  "preset": "ts-jest",
  "roots": ["<rootDir>/src"],
  "extensionsToTreatAsEsm": [".ts"],
  "testRegex": ".e2e-spec.ts$",
  "setupFiles": ["<rootDir>/src/preRun.ts"],
  "globals": {
    "ts-jest": {
      "useESM": true
    }
  }
}

Now when you call jest tell it to use es6 modules.

//in package.json scripts
   "test": "NODE_OPTIONS=--experimental-vm-modules npx jest"

node: schema in typescript

Node.js module implementation supports schemas. The "from" part of an import is really a url! And node cache treats it as as such. A really interesting schema is the node: schema so you can make it clear that this import is a node module and not a custom application module.

import fs from "node:fs";

There is one issue with this schema at the moment (June 2021) where the maintainers of the typescript types for Node.js tried to add this scheme but it caused issues for commonjs imports so they reverted the addition.

Right now you cannot use the node: schema with typescript and Node.js typings.

I'm sure this will be fixed in the future but just so you don't waste time trying to figure it out I thought I would share that investigation result!

Conclusion

ECMAScript es6 Modules are here and ready to be used!

It will be a while before you should use them in your browser web applications because of backwards compatibility concerns but in Node.js we control the runtime.

With some configuration changes to your typescript you can stop transpiling your es6 modules into commonjs and you’ll get some new useful features if you need them.