I'm going to just say it - the transition to ESM (ES Modules) in NodeJS has not been easy or pain-free for most developers, and continues to be a sore spot for many areas of NodeJS development, at least for now.
This page is my attempt at consolidating a lot of the issues that I have personally ran into trying to build and consume ESM code in NodeJS (particular with libraries / packages).
Required Reading / Resources
- NodeJS Docs: ESM API
- Sindre Sorhus:
- "Pure ESM Package" (good cheatsheet / FAQ for lib authors)
- "Get Ready for ESM"
- Github Discussion - "The ESM move"
- Gil Tayar: "Using ES Modules in Node.js" (part 1, part 2, and part 3)
- The Guild Blog: "What Does It Take to Support Node.js ESM"
- David Herron: ESM to CJS (guide on building ESM package that is consumable in CJS land)
Related Tooling and Libraries
standard-things/esm
package- This is an ESM loader, which lives outside of Node, and lets you back-port support to older versions, as well as do some other things
esmo
(underantfu/esno
)- Alias to
tsx
. Node runtime powered by esbuild, supports TS and ESM
- Alias to
- See my tooling section in my TS notes, and my NodeJS notes
NodeJS Built-Ins and Global Objects
Switching from CJS to ESM actually changes how some NodeJS built-ins function: __dirname
and __filename
. This is because these are not true globals, but rather injected variables provided by the module wrapper enclosure.
These will no longer be available when switching to ESM, but there are replacements you can use:
import { dirname } from 'path';
import { fileURLToPath } from 'url';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(fileURLToPath(import.meta.url));
Detecting if a file was run via the CLI, with require.main === module
also no longer works with ESM, for the same reason. An alternative (comparing process.argv[1]
with the filename) is outlined here, or you can use the es-main
package they link to.
Assorted Errors and Issues
Some of these might be specific to
ts-node
.
node-esm-resolve-implementation.... Error: ERR_MODULE_NOT_FOUND
- Make sure all imports include file and extension
- Change
import {foo} from './src
toimport {foo} from ./src/index.js
- Change
import {foo} from ./alpha
toimport {foo} from ./alpha.js
- Change
- Make sure all imports include file and extension
Error: Cannot find module '.../MyFile' imported by [...]
- If you are using the ESM loader, you need to specify the file extension (see error above this one)
- This includes imports from third party libraries under
node_modules
, if you are drilling down into the file structure
ERR_UNSUPPORTED_DIR_IMPORT
- Same as above - make sure all imports include file and extension.
- This throws specifically when trying to import via an
index
file without specifying the full path- Change
import {thing} from './utils
toimport {thing} from ./utils/index.js
- Change
Error [ERR_REQUIRE_ESM]: Must use import to load ES Module
- This can be caused be a few different things; a common cause being a dependency that is an ESM-only module (aka a Pure ESM Package), such as
node-fetch
- A pure ESM package kind of poisons your project
- The main fix is to convert your project to ESM. This guide covers how to do that (for both regular JS and TS), as well as some other options.
- This can be caused be a few different things; a common cause being a dependency that is an ESM-only module (aka a Pure ESM Package), such as
- Using NYC / Istanbul for code coverage shows zero percent (0%) coverage, in an ESM code base
- ESM is currently not supported with NYC, I would recommend using
c8
instead
- ESM is currently not supported with NYC, I would recommend using
Error: ERR_INVALID_MODULE_SPECIFIER ___ is not a valid package name
- If you are using TypeScript, this is probably a tsconfig path alias + ESM issue: reference the TypeScript section below for troubleshooting ideas
TypeError: IMPORTED_THING is not a constructor
- Can be hard to pin down, but often a case where
IMPORTED_THING
is actually the module, with something like{default: [class THING]}
, so you want to use.default
to pick off the actual thing itself from the import
- Can be hard to pin down, but often a case where
SyntaxError: The requested module '../my-file.js' does not provide an export named 'default'
- Check to make sure that you aren't accidentally forcing a CommonJS/CJS file (with
require
andmodule.exports
) to be treated as an ESM file- For example, I got this to trigger when I had a
ts-node
config withmoduleTypes: {"**/*.js": "esm"}
set, which forced all JS files to be treated as ESM, including one that was really CJS
- For example, I got this to trigger when I had a
- Check to make sure that you aren't accidentally forcing a CommonJS/CJS file (with
Augmenting Module Behavior Through CLI Options
- node flags, node options
-r
or--require
--experimental-specifier-resolution
- Example:
--experimental-specifier-resolution=node
- Example:
--loader
- ts-node
TS_NODE_PROJECT
- Point to a different
tsconfig
file
- Point to a different
TS_NODE_COMPILER_OPTIONS
- Inline augmentation of TS compiler options
- Example:
TS_NODE_COMPILER_OPTIONS='{"module":"commonjs"}'
- File module-type overrides
- Mocha
- You can put node options as top-level key-pairs in your
.mocharc.json
config file, like"loader": "ts-node/esm"
- You can put node options as top-level key-pairs in your
Next.js ESM
Next.js now mostly supports ESM, including the config file (in v12+). To use, rename your config file to next.config.mjs
, and set "type": "module"
in your package.json
.
If you get an error like SyntaxError: Named export '___' not found. The requested module './___.js' is a CommonJS module [...]
, check to make sure that you have set "type": "module"
in your package.json
file.
TypeScript and ESM
TypeScript and ESM - Resources
General resources for TS + ESM:
- @ddprrt: TypeScript and ECMAScript Modules
- Rauschmayer: TypeScript and Native ESM on Node.js
- My TypeScript cheatsheet has a few sections that overlap with this area
resolve-typescript-plugin
: Webpack plugin that fixes.js
imports in TS files in an ESM project
TypeScript - Fully Resolved Import Filepaths with Extensions
Full import filepaths are a sore spot within the ESM ecosystem, and especially with TS tooling.
Both NodeJS and the browser make it MANDATORY that file imports include the .js
extension:
// ESM - FAIL!
import { MyComponent } from './MyComponent';
// ESM - OK, this works
import { MyComponent } from './MyComponent.js'
The biggest issue with TypeScript and this requirement is that TypeScript will not automatically add the extension for your, even if you tell TSC that you are targeting ESM output. Furthermore, the TypeScript team has made it clear that this is by design, and not something they are willing to consider (see #16577 and #42151).
Unfortunately, even though fully resolved import paths are required for ESM, they can also break some different systems:
- Next.js
- CJS compatibility (TypeScript)
- If you have a set of files that you are trying to write as both CJS and ESM, you might run into issues here, especially with TypeScript
- As a side-note, if you are trying to author a hybrid / dual package (library that is released with both CJS and ESM exports) what you typically want to do is write your code using only one module pattern (ideally ESM), and then use build tooling to transpile to both module types
- For more details, see my "Building an NPM Package" page.
- Trying to import with CJS or MJS extension: You might run into the
cannot find module '../my-file.cjs' or its corresponding type declarations
for a CJS or MJS import in a TS file (or JS file, if you have checkJS on)- This has been fixed in a new (as of writing this) release of TS,
v4.5
, but requires the use ofnode12
ornodenext
for themoduleResolution
option, which is further restricted to just thebeta
ornightly
releases (for now)- See TS Issue #38784, closed by PR #45884
- This has been fixed in a new (as of writing this) release of TS,
TS-Node and ESM
ESM support is currently a WIP with
ts-node
. Main progress tracker is issue #1007 - please check that for most up-to-date reference.
TS-Node: ESM Loader
Many issues with ESM and ts-node can be fixed by using the correct loader.
If you have the newest version of TS-Node (>= v10.7.0
), you can now use any of these three consolidated approaches to load ESM:
Run
ts-node-esm
,ts-node --esm
, or add"ts-node": {"esm": true}
to your tsconfig.json.[source]
If you are using an older version, try these approaches:
Instead of using
ts-node ./my-file.ts
Use:
node --loader ts-node/esm ./my-file.ts
Or,
NODE_OPTIONS="--loader ts-node/esm" node ./my-file.ts
If you want to pass options to ts-node
, you can do so via env
variables (see docs for options and env
strings to use). However, there is one exception; for --transpile-only
, there is an ESM loader available to use directly:
node --loader ts-node/esm/transpile-only ./my-file.ts
The
ts-node
package does not need to be installed globally for the above loader commands to work.
TS-Node and General TypeScript + ESM Troubleshooting
- TSConfig path aliases don't seem to work with ESM
TypeError [ERR_UNKNOWN_FILE_EXTENSION]: Unknown file extension ".ts" for {FILEPATH}.ts
- If you are using ESM, use the specific ESM loader, as outlined above
- If you are trying to use CJS, make sure that your TSConfig is set appropriately for the
module
field - If you are getting this with an indirect consumer (e.g., a library that uses
ts-node
under the hood, such asknex
), you might need to force it to use the ESM loader via environmental variables. E.gl:NODE_OPTIONS="--loader ts-node/esm" node my-cmd ...
(bash syntax)
TypeError: ERR_UNKNOWN_FILE_EXTENSION is not a constructor
- This is probably an error caused by importing JSON. Normally, this works fine in TS with
resolveJsonModule: true
, but there are currently issues with combining this with ESM features - see this comment and TS Issue #46362- This should be fixed very soon
- This is probably an error caused by importing JSON. Normally, this works fine in TS with
TypeError: ERR_INVALID_MODULE_SPECIFIER: Invalid Module "file://[...].ts"
- Check if you are using
experimental-specifier-resolution
, but with CommonJS / CJS instead of ESM. This flag will cause an override.
- Check if you are using
ReferenceError: exports is not defined in ES module scope
- You probably have the TS module type set to
commonjs
. - Try using:
{ "compilerOptions": { "module": "ES2020", // Recommended "moduleResolution": "node" } }
- You probably have the TS module type set to