Headline: Node.js 22.12 and later can
require()a synchronous ES module, which removes the original reason most libraries shipped two builds, but it does not remove the dual package hazard and it does not forgive a badly orderedexportsmap. The three changes that fixed my publish were puttingtypesfirst anddefaultlast in every condition block, keeping exactly one implementation of module-level state, and runningpublintand@arethetypeswrong/cliin CI beforenpm publish.
Key takeaways
- Node.js 22.12.0 enabled
require(esm)by default on the 22.x line, and it has been on by default since Node.js 23.0.0. A CommonJS file can nowrequire()an ES module as long as that module graph contains no top-levelawait. -
require()of an ES module graph that uses top-levelawaitthrowsERR_REQUIRE_ASYNC_MODULE, notERR_REQUIRE_ESM. The two errors mean different things and have different fixes. - The
exportsfield inpackage.jsonmatches conditions in declaration order and the first match wins, sotypesmust come first anddefaultmust come last. Addingexportsalso blocks every subpath you did not list, which surfaces asERR_PACKAGE_PATH_NOT_EXPORTED. - The dual package hazard is what happens when one process loads both the CommonJS build and the ES module build of the same package: module-level state exists twice, singletons are no longer single, and
instanceofacross the two copies returnsfalse. - Named imports from a CommonJS file work only when
cjs-module-lexercan statically detect the export names. Exports assigned in a loop are invisible to it, and the consumer must use a default import instead.
The package was a few hundred lines of shared parsing helpers, published as ESM-only because every app I owned was already ESM. The first team to install it ran a CommonJS service, and it failed at boot with ERR_REQUIRE_ESM. My first instinct was to add a CommonJS build, which is the standard answer and also the answer that introduces the worst bug in this whole area.
Can Node.js require() an ES module now?
Yes, on Node.js 22.12.0 and later, and unflagged. require(esm) lets a CommonJS file load an ES module synchronously, returning the module namespace object, with the default export available as the .default property. The feature landed behind --experimental-require-module earlier in the 22.x line and was enabled by default in 22.12.0, and it is on by default in Node.js 23 and 24.
The single hard limit is asynchrony. require() is synchronous by definition, so if the ES module graph being required contains a top-level await anywhere, Node.js cannot finish evaluation before returning and throws ERR_REQUIRE_ASYNC_MODULE.
// app.cjs on Node.js 22.12+
const toolkit = require("@acme/toolkit"); // works if the ESM graph has no top-level await
console.log(toolkit.default); // the ESM default export
// If it throws ERR_REQUIRE_ASYNC_MODULE, the escape hatch is dynamic import
const mod = await import("@acme/toolkit"); // legal inside an async function in CJS
What this changes in practice: ESM-only is now a defensible choice for a new library targeting Node.js 22.12 and above. What it does not change: bundlers, older runtimes, and tools that still resolve the require condition will keep asking for a CommonJS entry point for a long time.
Why does my exports map resolve to the wrong file?
Because conditional exports are matched top to bottom and the first matching key wins, so ordering is semantics, not style. The types condition must be first or TypeScript may resolve a runtime file before it ever sees the declaration file. The default condition must be last, because it matches everything and shadows any condition placed after it.
{
"name": "@acme/toolkit",
"type": "module",
"exports": {
".": {
"types": "./dist/index.d.ts",
"react-server": "./dist/index.react-server.js",
"import": "./dist/index.js",
"require": "./dist/index.cjs",
"default": "./dist/index.js"
},
"./package.json": "./package.json"
},
"sideEffects": false
}
Exporting ./package.json explicitly keeps tools that read your manifest at runtime working, because an exports field otherwise makes it unreachable. The react-server condition is what React Server Components resolution uses, and Next.js resolves it for any module imported from a Server Component.
The failure mode people hit hardest is subpath blocking. The moment you add exports, everything not listed is private, and consumers who were deep-importing @acme/toolkit/dist/internal/parse.js get ERR_PACKAGE_PATH_NOT_EXPORTED. That is a breaking change even when nothing else in the package moved.
What is the dual package hazard, and how do I avoid it?
The dual package hazard is the bug where one Node.js process loads both builds of the same package, so every module-level value exists twice. Node.js keeps separate caches for CommonJS and ES modules, so dist/index.js and dist/index.cjs are two distinct modules with two distinct copies of every class, registry, and counter you declared at module scope.
import { Token } from "@acme/toolkit"; // resolves ./dist/index.js
const { Token: TokenCjs } = require("@acme/toolkit"); // resolves ./dist/index.cjs
new Token("a") instanceof TokenCjs; // false — same name, two classes
It is nasty precisely because it is silent. Nothing throws. A validation check fails for a value that is obviously the right type, or a cache that should be shared reports a miss, and the stack trace points at your logic rather than at your packaging.
| Strategy | What you ship | Hazard risk | When I pick it |
|---|---|---|---|
| ESM-only | One ES module build, no require condition |
None — only one copy can exist | New libraries targeting Node.js 22.12+, and anything holding state or exporting classes |
| ESM plus a stateless CommonJS wrapper | ES module implementation, plus a small CommonJS file that re-exports it | None, if the wrapper holds no state | Libraries that must keep older CommonJS consumers working |
| Two full builds | Independently bundled CommonJS and ES module outputs | High — both graphs can load at once | Only for pure functions with zero module-level state and no exported classes |
My rule now is simple: if the package exports a class, holds a registry, or memoizes anything at module scope, it ships one implementation.
Why can't I destructure named exports from a CommonJS module?
Because Node.js discovers the named exports of a CommonJS file by static analysis, using cjs-module-lexer, and that analysis only sees assignments it can read literally in the source.
// legacy.cjs — the lexer cannot see these
for (const name of ["parse", "format"]) {
module.exports[name] = impl[name];
}
// consumer.mjs
import { parse } from "./legacy.cjs"; // SyntaxError: no export named 'parse'
import legacy from "./legacy.cjs"; // works: the whole module.exports object
const { parse } = legacy; // destructure at runtime instead
If you own the CommonJS file, write the assignments out literally, one exports.parse = ... per name, so the lexer can find them. If you do not own it, default-import the module object and destructure afterwards.
How do I configure TypeScript so the emitted output matches the package format?
Set "module": "nodenext" and "moduleResolution": "nodenext", and let the nearest package.json type field decide how each file is emitted. Under nodenext, a .ts file in a "type": "module" package is an ES module, .mts is always ESM, .cts is always CommonJS, and relative imports need file extensions.
{
"compilerOptions": {
"module": "nodenext",
"moduleResolution": "nodenext",
"verbatimModuleSyntax": true,
"declaration": true,
"outDir": "dist"
}
}
verbatimModuleSyntax tells TypeScript to emit import and export statements exactly as written, dropping only those marked import type. TypeScript 5.8 taught --module nodenext about require() of an ES module, so a .cts file requiring an ESM dependency is no longer a compile error on runtimes that support it.
The declaration side has its own trap: one .d.ts file cannot describe both formats correctly, because the module kind of a declaration file is inferred the same way as a source file. Dual packages need .d.mts and .d.cts alongside their respective entry points.
How do I verify a package before publishing it?
Run publint and @arethetypeswrong/cli against the packed tarball, then smoke-test both import styles in a real Node.js process. publint lints the manifest for packaging errors such as condition ordering and format mismatches. @arethetypeswrong/cli, invoked as attw, checks that type declarations resolve correctly for every consumer configuration, and it catches masquerading types where a CommonJS entry point is described by an ESM declaration file.
npm pack
npx publint
npx @arethetypeswrong/cli --pack .
# Smoke-test both resolution paths against the real runtime
node --input-type=module -e "import('@acme/toolkit').then(m => console.log(Object.keys(m)))"
node --input-type=commonjs -e "console.log(Object.keys(require('@acme/toolkit')))"
I wire both CLIs into the same CI job that runs the build, so a bad exports map fails the pipeline instead of failing someone else's install. Packaging errors are invisible in your own repository and only ever appear in a consumer's.
FAQ
Q: Does require(esm) mean I can drop my CommonJS build entirely?
A: Only if every consumer runs Node.js 22.12 or later and no bundler in their toolchain insists on the require condition. For a library with unknown consumers, a thin stateless CommonJS wrapper is cheap insurance and carries no dual package hazard.
Q: What is the difference between ERR_REQUIRE_ESM and ERR_REQUIRE_ASYNC_MODULE?
A: ERR_REQUIRE_ESM means the runtime will not load an ES module through require() at all, which on modern Node.js usually points at resolution picking an unexpected file. ERR_REQUIRE_ASYNC_MODULE means the runtime tried and stopped because the module graph uses top-level await.
Q: Why did adding an exports field break my consumers?
A: An exports field makes every path you did not list unreachable, so any consumer deep-importing an internal file now gets ERR_PACKAGE_PATH_NOT_EXPORTED. Publish it as a major version, or list the previously reachable subpaths explicitly during a deprecation window.
Q: Do I still need the main field in package.json?
A: Keep main as a fallback for tooling that predates conditional exports. Node.js ignores main whenever an exports field is present, so it costs nothing and buys compatibility with older bundlers and resolvers.
Q: How do I get __dirname in an ES module?
A: Use import.meta.dirname and import.meta.filename, available since Node.js 20.11 and 21.2. For older runtimes, derive it with fileURLToPath(import.meta.url) from node:url.
Originally published on devya.dev. Also on eng-ahmed.com. Built by Devya Solutions.
Top comments (0)