- Book: TypeScript in Production
- Also by me: The TypeScript Library — the 5-book collection
- My project: Hermes IDE | GitHub — an IDE for developers who ship with Claude Code and other AI coding tools
- Me: xgabriel.com | GitHub
On April 30, 2026, Node.js 20 reached end of life. That left three supported release lines on the Node.js release schedule: 22 in maintenance LTS, 24 in active LTS, and 26 as Current. On a current patch, every one of them can require() an ES module with no flag.
So look at the second build in your library's dist/ folder. The CommonJS copy you ship next to the ESM one, so that require('your-lib') keeps working, is now insurance for runtimes the Node project no longer patches, and for tools that bring their own require().
You can usually drop it. The rebuild comes down to "type": "module" and a shorter exports map, and a ten-line CommonJS script proves the package still loads.
What changed in Node, and when
The history table in the Node.js modules docs has the dates. Loading ESM from require() came out from behind the --experimental-require-module flag in v23.0.0, v22.12.0 and v20.19.0. The experimental warning stopped printing by default in v23.5.0, v22.13.0 and v20.19.0. In v25.4.0 the feature stopped being experimental at all. Node 24 and 26 were cut after 23, so they have it from their first release.
There are two conditions. The module must be fully synchronous, meaning no top-level await anywhere in its graph. And Node must know it is ESM: a .mjs file, or a .js file whose nearest package.json says "type": "module". When both hold, require() returns the module namespace object, the same thing import() would give you, only synchronously.
If you need to branch on it at runtime, the same docs say process.features.require_module is true when the feature is available.
The timing matters for one more reason. Per the release schedule, Node 24 moves to maintenance on October 20, 2026 and Node 26 becomes active LTS on October 28. The oldest line anyone should deploy is 22, and on a patched 22 this works.
The library, rebuilt with one build
The example is a tiny string utility package, @acme/slug, with two functions. Here is the layout:
slug/
package.json
tsconfig.json
src/
index.ts
slugify.ts
truncate.ts
The source is ordinary TypeScript. The only ESM-specific detail is the .js extension on relative imports, which nodenext requires because Node resolves the emitted files, not your .ts ones.
// src/slugify.ts
export function slugify(input: string): string {
return input
.normalize("NFKD")
.replace(/\p{M}/gu, "")
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-+|-+$/g, "");
}
// src/truncate.ts
export function truncate(s: string, max: number): string {
if (s.length <= max) return s;
return s.slice(0, Math.max(0, max - 1)) + "…";
}
// src/index.ts
export { slugify } from "./slugify.js";
export { truncate } from "./truncate.js";
The package.json is where the second build disappears. One "type", one exports entry, no "require" condition, no dist/cjs:
{
"name": "@acme/slug",
"version": "2.0.0",
"type": "module",
"exports": {
".": {
"types": "./dist/index.d.ts",
"default": "./dist/index.js"
}
},
"files": ["dist"],
"engines": { "node": ">=22.12.0" },
"scripts": {
"build": "tsc -p tsconfig.json",
"check": "publint && attw --pack . --profile esm-only",
"prepublishOnly": "npm run build && npm run check"
},
"devDependencies": {
"@arethetypeswrong/cli": "latest",
"publint": "latest",
"typescript": "latest"
}
}
A require('@acme/slug') call resolves the "default" condition just like an import does, so both consumers land on the same dist/index.js. Pin real versions in devDependencies in your own repo; latest keeps the example short.
The version goes to 2.0.0 on purpose. Removing a CommonJS build is a breaking change for anyone on an old runtime, and semver should say so.
The tsconfig.json leaves the output format to nodenext:
{
"compilerOptions": {
"module": "nodenext",
"target": "es2022",
"strict": true,
"declaration": true,
"declarationMap": true,
"sourceMap": true,
"verbatimModuleSyntax": true,
"rootDir": "src",
"outDir": "dist"
},
"include": ["src"]
}
"module": "nodenext" does the format detection. The TypeScript modules reference spells it out: .ts files are ES modules if the nearest package.json contains "type": "module", and the detected format decides what gets emitted. With "type": "module" set, tsc writes ESM, .d.ts files and their source maps into dist/, and no CommonJS.
Prove it from a real CommonJS consumer
Do not trust the config. Pack the library and install it into a project that is CommonJS on purpose.
cd slug && npm run build && npm pack
mkdir ../consumer && cd ../consumer
npm init -y && npm pkg set type=commonjs
npm install ../slug/acme-slug-2.0.0.tgz
Then a consumer that calls require() and asserts on the results, so a pass means something:
// consumer/index.cjs
"use strict";
const assert = require("node:assert/strict");
const { slugify, truncate } = require("@acme/slug");
assert.equal(process.features.require_module, true);
assert.equal(slugify("Héllo, Wörld"), "hello-world");
assert.equal(truncate("abcdef", 4), "abc…");
console.log("CJS consumer OK on", process.version);
Run node index.cjs on 22.12 or later, 24, and 26. If every assert passes, the script prints its line and exits 0. On a Node release that predates the unflagging, the require() line throws ERR_REQUIRE_ESM instead. Put this file in CI across your supported Node versions and you have a test for the exact promise the dual build used to make.
TypeScript consumers that still compile to CommonJS need one setting. TypeScript 5.8 stopped reporting errors on require() calls to ESM files under --module nodenext. Under node16 and node18 that call is still an error, per the TypeScript modules reference, so a consumer pinned to those modes will see one until it moves to nodenext.
Four places it still bites
Top-level await. If the module you require() uses top-level await, or anything it imports does, Node throws ERR_REQUIRE_ASYNC_MODULE, and the advice is to load that module with import() instead, per the modules docs. TypeScript will not save you here. Its modules reference says it "does not attempt to detect this case and will not emit a compile-time error." The usual offender is a config module like this:
// src/config.ts: breaks every require() caller
const res = await fetch("https://example.com/cfg.json");
export const config = await res.json();
Turn it into a function the caller awaits, and the graph is synchronous again:
// src/config.ts: safe to require()
export async function loadConfig(): Promise<unknown> {
const res = await fetch("https://example.com/cfg.json");
return res.json();
}
If a dependency deep in the graph is the culprit, Node's --experimental-print-required-tla flag tries to print where the top-level awaits are when the error goes uncaught.
Default exports. require() hands back the namespace object, so a default export arrives as .default. A v1 consumer who wrote const slugify = require("@acme/slug") gets an object, not a function. The cleanest fix is to ship named exports only, as above. If you must keep the old call shape, Node supports a string export named "module.exports" that replaces what require() returns. That interop landed in v23.0.0 and v22.12.0, and TypeScript accepts string export names since 5.6:
// src/index.ts: v1-compatible shape
import { slugify } from "./slugify.js";
import { truncate } from "./truncate.js";
const api = Object.assign(slugify, { slugify, truncate });
export { slugify, truncate };
export { api as "module.exports" };
The catch, from the Node docs: once "module.exports" is exported, named exports are lost to CommonJS callers. That is why truncate and slugify are attached as properties of the exported function, so const { truncate } = require(...) still works. ESM importers keep using the named exports. I haven't checked how tsc types this shape for CommonJS callers, so run the attw check below and test the call from TypeScript too.
Consumers on Node 22.0 to 22.11. Those patches shipped before the unflagging. The "engines": { "node": ">=22.12.0" } field documents the floor, but the npm docs say it is advisory and only produces warnings when your package is installed as a dependency. So say it in the README too, and give stuck users their three options: update to a current 22 patch, which is the real fix; run Node with --experimental-require-module, which those versions already have; or load the library with await import("@acme/slug") from an async function.
Test runners with their own require(). Some tools implement require() themselves instead of handing it to Node, so Node's version alone does not settle it. Jest is one. Its ECMAScript modules docs, for the unreleased next version, say it supports require() of an ES module only on Node v24.9 and later, and that on older Node the call still throws ERR_REQUIRE_ESM. A consumer whose CommonJS test suite pulls in your package under Jest can break on Node 22.12 even though their app runs fine. Say so in the 2.0.0 release notes, so those users check their Jest setup before they upgrade.
Check the package before you publish
Two tools catch packaging mistakes that your own tests won't, because unit tests usually run against source and these run against what would actually be published.
publint lints the package against how Node and bundlers will resolve it. Run npx publint after the build so it sees the real dist/.
arethetypeswrong checks that the types resolve the way the JavaScript does under each resolution mode. Run attw --pack . so it packs the directory first. Pass --profile esm-only, which the README describes as ignoring CJS resolution failures. Without it, attw reports the CommonJS row as a problem, because its own docs still describe a require that resolves to ESM as "an error in Node and some bundlers." That is accurate for older runtimes and is the trade you just made deliberately. The CommonJS consumer test above is what covers the require() path now.
The prepublishOnly script in the package.json wires both checks in, so npm publish builds the package and stops if either check fails.
Before you delete dist/cjs, put index.cjs in CI on every Node line you support, run both checks, and only then cut 2.0.0.
If this was useful
Dropping the CommonJS build is one decision in a longer list: how exports conditions resolve, which module setting matches your runtime, and when a dual build is still worth it. TypeScript in Production covers that library-authoring layer, along with tsconfig, build tools, monorepos, and JSR. If you ship TypeScript at work, it is the one to start with: TypeScript in Production.
The full shelf, so you can pick the path that fits you:
- TypeScript Essentials — types, narrowing, modules, async, daily-driver tooling
- The TypeScript Type System — generics, mapped/conditional types, infer, template literals, branded types
- Kotlin and Java to TypeScript — JVM developers moving to TypeScript
- PHP to TypeScript — PHP 8+ developers moving to TypeScript
- TypeScript in Production — tsconfig, build tools, monorepos, library authoring, dual ESM/CJS, JSR
Books 1 and 2 are the core path. Coming from the JVM or PHP, books 3 or 4 replace them. Book 5 is for anyone shipping TypeScript at work. The whole set is at The TypeScript Library.
All five books ship in ebook, paperback, and hardcover.

Top comments (0)