Update
The documentation for Nuxt 3 also mentions similar content, which can be read for reference: https://nuxt.com/docs/guide/concepts/esm#what-kinds-of-problems-can-there-be
Recently, I've been struggling with how to write a library that is compatible with both CJS and ESM pure. This is not just about using tsc or a bundler to directly generate cjs and esm format js files. Since NodeJS began supporting the new exports
field in package.json
and officially supporting ESM, writing a library that works in a project with type: "module"
has become so complex that some libraries, like Google's zx (which dropped CJS support starting from v5), and others like nanoid, chalk, etc., have also begun to abandon support for CJS. This has made it impossible to continue using updated versions of dependency libraries in projects that only support CJS. Currently, most Node backend projects cannot accommodate a pure ESM environment.
First, in a project, there may be a large number of libraries that are no longer maintained or whose authors do not keep up with updates. These libraries only support CJS, which means that in an ESM environment, one has to use createRequire() to work around the issue, or can only use default exports instead of named exports. Whether using createRequire or converting everything to default exports and then destructuring is not a good solution. The second reason is that most projects are using TypeScript. This will be elaborated on later.
// my-module/lib/index.js
module.exports = {
name: 'my-module-cjs',
}
// import-cjs.js
import { createRequire } from 'module'
import path from 'path'
const require = createRequire(path.resolve(import.meta.url, './node_modules'))
const { name } = require('my-module/lib/index')
console.log(name)
import { name } from 'my-module' // error, info below
// import { name } from 'my-module'
// ^^^^
// SyntaxError: Named export 'name' not found. The requested module 'my-module' is a CommonJS module, which may not support all module.exports as named exports.
// CommonJS modules can always be imported via the default export, for example using:
//
// import pkg from 'my-module';
// const { name } = pkg;
In a Vanilla NodeJS environment, can I mix two types of libraries after enabling ESM?#
Putting aside TypeScript projects, the answer is yes. The previous section demonstrated how to use CJS modules in ESM. Because in pure JS, it will not be compiled, the import statement remains import at runtime, and require remains require; it will not be translated into import -> require
or await import() -> Promise.resolve().then(() => require())
by TypeScript or other compilers.
In package.json, adding "type": "module"
means that the project is enabled for ESM by default, and all .js
suffixes will be recognized by Node as ES Modules, while only .cjs
suffixes will be considered CommonJS. Therefore, in .js
files, you can use import to import various ESM libraries or use createRequire() to import CommonJS libraries. In .cjs
, you can still use require() and await import()
as usual.
// test.js
export const obj = {
a: 1,
}
// test.cjs
import('./test.js').then(({ obj }) => {
console.log(obj)
})
Can't I use ESM Pure libraries in a CJS environment?#
The answer is yes, but you can only use asynchronous imports like:
async function main() {
const { $ } = await import('zx')
await $`ls`
}
main()
So why are many libraries starting to abandon CJS support? I think there are several reasons:
- CJS is really hard to be compatible with
- ESM is the trend
- The reason related to TypeScript compilation output (which has been alleviated in TSC 4.7)
Pitfalls of using only CJS libraries in ESM#
Next, let's look at a situation where ESM is enabled in a project, and then axios is imported. Axios only supported CommonJS before v1. The following code is an example:
import { AxiosError } from 'axios'
console.log(AxiosError)
This will throw an error because axios is a CJS module and can only be used with require. If you directly import it, named exports are not supported. Instead, it should be changed to:
import axios from 'axios'
console.log(axios.AxiosError)
However, this way, the tsserver will not provide type hints for axios.
Using named imports can provide proper type hints. Of course, this is also because axios's typing is handwritten and not generated by tsc. Alternatively, writing it this way can also provide type hints. Isn't it quite troublesome?
ESM import third-party libraries and reading package.json#
Let's look at another situation. This is one of my libraries, modeled after many libraries that output both cjs/esm, with the following package.json.
my-module
├── esm
│ └── index.js
├── lib
│ └── index.js
└── package.json
The esm directory contains the ESM output, while lib contains the CJS. In package.json, I defined "module": "esm/index.js"
to indicate that if ESM is supported, this should be prioritized as the import file (without using the exports field). However, the reality is that when I import it in index.js
, it still defaults to choosing lib/index.js as the entry point.
// my-module/lib/index.js
export default {
name: 'my-module-esm',
}
// .../lib/...
module.exports = {
name: 'my-module-cjs',
}
// index.js
import module from 'my-module'
console.log(module) // { name: 'my-module-cjs' }
What does this affect? For example, the SSR framework Rakkajs that I have been using recently has a server output that is purely ESM, and in production, it discards Vite's pre-build, causing many libraries to crash directly in production due to import issues. For instance, the aforementioned axios or react-use libraries.
So what happens if we directly import esm/index.js?
// esm/index.js
export default {
name: 'my-module-esm',
}
// index.js
import module from 'my-module/esm/index.js'
console.log(module)
The answer is that it will throw an error. Why? First, your project has ESM enabled, meaning that by default, all .js
files are ESM. However, the library you are importing is not ESM. Secondly, it also does not have "type": "module"
enabled in its package.json, which means that its .js
files should be CommonJS. However, this file uses ESM's export default syntax, which is incorrect.
at 16:12:03 ❯ node index.js
(node:18949) Warning: To load an ES module, set "type": "module" in the package.json or use the .mjs extension.
(Use `node --trace-warnings ...` to show where the warning was created)
/private/tmp/test/node_modules/my-module/esm/index.js:1
export default {
^^^^^^
SyntaxError: Unexpected token 'export'
But the solution is quite troublesome. You have to modify the library.
- Either directly add
type: module
to the library, which would render CJS obsolete - Change the suffix of each ESM file to
.mjs
The second method seems simple, and indeed it works after the change.
// my-module/esm/index.mjs
export default {
name: 'my-module-esm',
}
// index.js
import module from 'my-module/esm/index.mjs'
console.log(module) // { name: 'my-module-esm' }
But you would be mistaken. Suppose my library's ESM is like this.
// my-module/esm/index.mjs
import { add } from './utils'
export default {
name: 'my-module-esm',
}
I have an import for utils, which seems normal, but at runtime, it will fail because utils.js cannot be found. Why can't it be found? Because utils.mjs cannot omit the file extension.
at 16:17:34 ❯ node index.js
node:internal/errors:477
ErrorCaptureStackTrace(err);
^
Error [ERR_MODULE_NOT_FOUND]: Cannot find module '/private/tmp/test/node_modules/my-module/esm/utils' imported from /private/tmp/test/node_modules/my-module/esm/index.mjs
Then you need to change it to:
+ import { add } from './utils.mjs'
- import { add } from './utils'
Now it works, but the mindset is completely broken. Manually changing is basically impossible; it's not just a matter of changing a file extension; import replacements would require writing an AST script.
Huge pitfalls in TypeScript projects#
Did you think it would be fine? No, in TypeScript, it can be a complete disaster. First, let's talk about the situation where "moduleResolution": "NodeNext"
and "module": "NodeNext"
are not enabled in tsconfig.json, and the general setting is "module": "CommonJS"
. At the same time, ESM is also disabled in the project.
As mentioned earlier, in CJS, how to use ESM is either to use the .mjs suffix and then use import(), or directly import() a third-party library. However, everyone knows that in TypeScript, who still uses require? It's all import, and anyway, tsc will eventually translate it into require. The problem lies here. This means I cannot import any ESM libraries at all because import() is translated into require by tsc.
async function main() {
const { $ } = await import('zx')
await $`ls`
}
main()
Translates to:
async function main() {
const { $ } = await Promise.resolve().then(() => __importStar(require('zx')));
await $ `ls`;
}
main();
Directly fails.
at 16:33:29 ❯ node dist/index.js
node:internal/process/promises:279
triggerUncaughtException(err, true /* fromPromise */);
^
Error [ERR_REQUIRE_ESM]: require() of ES Module /private/tmp/test/node_modules/.pnpm/[email protected]/node_modules/zx/build/index.js from /private/tmp/test/dist/index.js not supported.
Instead change the require of /private/tmp/test/node_modules/.pnpm/[email protected]/node_modules/zx/build/index.js in /private/tmp/test/dist/index.js to a dynamic import() which is available in all CommonJS modules.
at /private/tmp/test/dist/index.js:25:67
at async main (/private/tmp/test/dist/index.js:25:19) {
code: 'ERR_REQUIRE_ESM'
}
Then, TypeScript added "module": "NodeNext"
in version 4.7, enabling it. Let's try again.
import axios from 'axios'
const $axios = axios.create({})
async function main() {
const { $ } = await import('zx')
await $`ls`
}
main()
Compiles to:
"use strict";
var __importDefault = (this && this.__importDefault) || function (mod) {
return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", { value: true });
const axios_1 = __importDefault(require("axios"));
const $axios = axios_1.default.create({});
async function main() {
const { $ } = await import('zx');
await $ `ls`;
}
main();
Now it translates correctly, distinguishing between import and require. But another problem arises: axios is just a CJS module, and its package.json does not define the "exports"
field. If it encounters a package that has this field, it will be another headache. For example, take nanoid as an example. The nanoid package defines exports.
"exports": {
".": {
"types": "./index.d.ts",
"browser": "./index.browser.js",
"require": "./index.cjs",
"import": "./index.js",
"default": "./index.js"
},
Now TypeScript does not know whether to use import or require, and it fails again.
import nanoid from 'nanoid'
nanoid() // error TS1479: The current file is a CommonJS module whose imports will produce 'require' calls; however, the referenced file is an ECMAScript module and cannot be imported with 'require'. Consider writing a dynamic 'import("nanoid")' call instead.
To convert this file to an ECMAScript module, change its file extension to '.mts', or add the field `"type": "module"` to '/private/tmp/test/package.json'.
Then you have to manually change it to the require form.
const { nanoid } = require('nanoid')
nanoid()
Then the types are lost, and everything is any.
This is just the situation when the project does not enable ESM; if it is enabled, the situation becomes even more complicated. It's really exhausting.
Conclusion#
Since it is so troublesome to be compatible with both CJS and ESM, it might be better to just abandon CJS compatibility altogether and go for ESM Pure directly. This way, there is no need to distinguish file extensions or write a bunch of exports. Everything is set. However, this can be painful for library users, especially in SSR situations.
This article is synchronized and updated to xLog by Mix Space. The original link is https://innei.ren/posts/programming/write-a-nodejs-library-in-2022