NodeJS, Typescript, and the infuriating ESM errors

Mixing CommonJS and ESM with NodeJS, Typescript, and Jest

Christian Clausen
6 min readOct 23, 2024

--

I’ve encountered this issue a few times now, and none of the existing blog posts seem to work for my situation, so I’m writing it up so I myself, and perhaps others can use my solution next time we encounter the error. I’m not going to explain what’s happening under the hood, only how to fix it.

Background: There is a new way to make Javascript modules called ESM. Their advantage is that they work both with NodeJS (back-end) and in a browser (front-end), so you can use the same libraries.

But! You cannot immediately mix normal (CommonJS) and ESM dependencies. Not many old libraries have been updated to ESM yet, and for some it doesn’t make sense at all. Like, you’d never use Express in a front-end.

So when you add your first ESM dependency you get:

Error [ERR_REQUIRE_ESM]: require() of ES Module C:\XXXX.js not supported.
Instead change the require of XXXX.js in C:\XXXX.js to
a dynamic import() which is available in all CommonJS modules.
at Object.<anonymous> (C:\XXXX.js:8:16) {
code: 'ERR_REQUIRE_ESM'
}

Then, to fix this you add "type": "module", to your package.json. But now you get this error:

ReferenceError: exports is not defined in ES module scope
This file is being treated as an ES module because it has a '.js' file
extension and 'C:\XXXX\package.json' contains "type": "module". To treat it
as a CommonJS script, rename it to use the '.cjs' file extension.
at file:///C:/XXXX.js:5:23
at ModuleJob.run (node:internal/modules/esm/module_job:218:25)
at async ModuleLoader.import (node:internal/modules/esm/loader:329:24)
at async loadESM (node:internal/process/esm_loader:34:7)
at async handleMainPromise (node:internal/modules/run_main:113:12)

What combination of target, module, moduleResolution, and runic incantations can resolve this? Well, here is the setup that worked for me.

Setup with Minimal Maintenance

The tsconfig.json file has become quite complicated. There are so many options to mess around with, and some influence others. Like, you can have "module": "CommonJS" with "moduleResolution": "Node", but not with "moduleResolution": "Node16"? From my point of view, these are annoying implementation details that I don’t care about. There are a couple of configuration options I care about (see later), but mostly I just want my code to run, preferably with as little maintenance as possible.

So, if you’re like me, here is what you do.

Outsource tsconfig.json

“Outsource”, is that gonna be hard to do? No, it’s gonna be super easy. Barely an inconvenience. Because someone already made a repository with all the right settings for pretty much every situation. They even made the settings available through NPM.

I like to use node-lts and they have a setting for exactly this. To use it simply add this devDependency:

"devDependencies": {
"@tsconfig/node-lts": "latest",
...
}

Note: Some people say that using latest exposes your code to unnecessary risk, since it will install breaking changes immediately. There are two reasons I disagree.

  1. I don’t trust the maintainers. These same people from above are often okay with auto-updating minor or at least patch versions. However, you might also get a breaking change from this. The only difference is whether the maintainer knows it has a breaking change or not. I expect they make mistakes sometimes, and I don’t want to rely on them adding “Deprecated” to old things, so I am reminded to update them.
  2. I’m not paranoid. To be safe from unexpected breakage you’d have to lock down your versions exactly. You’d also need to host them locally in case the package repository changes something. I cannot work with this level of paranoia.
  3. I want to be up-to-date. If there are major updates I want to know about them. Especially since one of the biggest attack vectors is out-of-date dependencies. Sometimes my build will fail because of a breaking change, if I have the time I fix the code to the new version. If I don’t have the time, I look up what version we were on before and replace latest with that version — until I have time to come back and actually update.
  4. If it hurts do it more. Getting these breaking changes little by little is much less grueling than an “upgrade packages”-task with 30 dependencies that want to pull the code in different directions.
  • As always: Add as few dependencies as possible. Ensure they are necessary, then audit them and verify their maintainers are reputable.

After modifying the dependencies remember to run npm update. Then in my tsconfig.json I put:

{
"extends": "@tsconfig/node-lts/tsconfig.json",
"compilerOptions": {
"outDir": "./out" /* Redirect output structure to the directory. */,
"rootDirs": [
"src"
] /* List of root folders whose combined content represents the structure of the project at runtime. */,
"resolveJsonModule": true
}
}

Become a Module

As I said earlier we add "type": “module", to our package.json and get a bunch of compile errors from importing our own files.

src/XXXX.ts:X:XX - error TS2835: Relative import paths need explicit file 
extensions in ECMAScript imports when '--moduleResolution' is 'node16' or
'nodenext'. Did you mean './XXXX.js'?

1 import { xxxxx } from "./XXXX";

Initially, its suggestion seems odd. Adding a .js extension to our imports feels weird when they are written in Typescript. If we do that, won't we lose the type information? As it turns out: No. It just works (for once). So go on, add .js to all the imports that start with ..

Bonus: Import .json Files

This is not necessary, but if you’re like me and like to print the name or version from the package.json you now get an error like:

node:internal/modules/esm/assert:89
throw new ERR_IMPORT_ASSERTION_TYPE_MISSING(url, validType);
^

TypeError [ERR_IMPORT_ASSERTION_TYPE_MISSING]: Module
"file:///C:/XXXX/package.json" needs an import attribute of type "json"
at validateAttributes (node:internal/modules/esm/assert:89:15)
at defaultLoad (node:internal/modules/esm/load:150:3)
at async ModuleLoader.load (node:internal/modules/esm/loader:409:7)
at async ModuleLoader.moduleProvider (node:internal/modules/esm/loader:291:45) {
code: 'ERR_IMPORT_ASSERTION_TYPE_MISSING'
}

To fix this, add this to the import

import conf from "../package.json" with { type: "json" };

Which might give the error:

Import attributes are only supported when the '--module' option is set to 
'esnext', 'nodenext', or 'preserve'.

Which just means that it isn’t fully implemented yet. However, it still works, and this error will disappear by itself once the feature is fully released. Until such time we can suppress the error with:

// @ts-ignore <-- TODO remove this line, unless that gives an error
import conf from "../package.json" with { type: "json" };

Bonus: Test with Jest

You might now be experiencing that your Jests are failing with:

Jest encountered an unexpected token

Jest failed to parse a file. This happens e.g. when your code or its
dependencies use non-standard JavaScript syntax, or when Jest is not
configured to support such syntax.

Out of the box Jest supports Babel, which will be used to transform your
files into valid JS based on your Babel configuration.

By default "node_modules" folder is ignored by transformers.

Here's what you can do:
• If you are trying to use ECMAScript Modules, see
https://jestjs.io/docs/ecmascript-modules for how to enable it.
• If you are trying to use TypeScript, see
https://jestjs.io/docs/getting-started#using-typescript
• To have some of your "node_modules" files transformed, you can specify
a custom "transformIgnorePatterns" in your config.
• If you need a custom transformation specify a "transform" option in
your config.
• If you simply want to mock your non-JS modules (e.g. binary assets)
you can stub them out with the "moduleNameMapper" config option.

You'll find more details and examples of these config options in the docs:
https://jestjs.io/docs/configuration
For information about custom transformations, see:
https://jestjs.io/docs/code-transformation

Details:

C:\XXXX\out\tests\XXX.test.js:1
XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX
^^^^^^

SyntaxError: Cannot use import statement outside a module

at Runtime.createScriptFromCode (node_modules/jest-runtime/build/index.js:1505:14)

To solve this we have to tell Jest that we are now producing an ESM module. We do this with the jest.config file. That looks like this:

const { createDefaultEsmPreset } = require("ts-jest");

const defaultEsmPreset = createDefaultEsmPreset();

/** @type {import('ts-jest').JestConfigWithTsJest} */
module.exports = {
testEnvironment: "node",
// [... whatever else you want to configure]
...defaultEsmPreset,
moduleNameMapper: {
"^(\\.{1,2}/.*)\\.js$": "$1",
},
};

If we save it as .js we get this error:

ReferenceError: require is not defined in ES module scope, you can use import 
instead This file is being treated as an ES module because it has a '.js'
file extension and 'C:\XXXX\package.json' contains "type": "module". To treat
it as a CommonJS script, rename it to use the '.cjs' file extension.
at file:///C:/XXXX/jest.config.js:1:36
at ModuleJob.run (node:internal/modules/esm/module_job:218:25)
at async ModuleLoader.import (node:internal/modules/esm/loader:329:24)
at async importModuleDynamicallyWrapper (node:internal/vm/module:431:15)
at async requireOrImportModule (C:\XXXX\node_modules\jest-util\build\requireOrImportModule.js:55:32)
at async readConfigFileAndSetRootDir (C:\XXXX\node_modules\jest-config\build\readConfigFileAndSetRootDir.js:112:22)
at async readInitialOptions (C:\XXXX\node_modules\jest-config\build\index.js:403:13)
at async readConfig (C:\XXXX\node_modules\jest-config\build\index.js:147:48)
at async readConfigs (C:\XXXX\node_modules\jest-config\build\index.js:424:26)
at async runCLI (C:\XXXX\node_modules\@jest\core\build\cli\index.js:151:59)

Following it’s advice and renaming it to jest.config.cjs. Works like a charm.

Conclusion

After a quite of fiddling around and a lot of errors we can now use both CommonJS and ESM dependencies, with Typescript, NodeJS, and even Jest. Perhaps this is easier if you use node-ts, but I like regular node, so I’m holding out a bit.

I hope my struggles have saved you from some.

--

--

Christian Clausen
Christian Clausen

Written by Christian Clausen

I live by my mentor’s words: “The key to being consistently brilliant is: hard work, every day.”

No responses yet