Shipping ESM in Node Without Breaking Everyone: A TS 4.7 Guide
TypeScript 4.7 shipped module: "nodenext", the last missing piece for writing native ES modules in Node with TypeScript. The ecosystem transition is still messy; here's the working configuration and the traps.
The tsconfig that works
{
"compilerOptions": {
"module": "NodeNext",
"moduleResolution": "NodeNext",
"target": "ES2022",
"strict": true
}
}
And in package.json:
{ "type": "module" }
Now .ts files compile to ESM .js. The immediate surprise: relative imports need explicit .js extensions — yes, .js even in .ts source, because you're writing the output path:
import { parseOrders } from "./orders/parse.js"; // resolves parse.ts
It reads wrong and works right. Editors auto-import correctly with the config above.
Dual packages via exports maps
Publishing a library that serves both worlds:
{
"exports": {
".": {
"import": "./dist/index.js",
"require": "./dist/index.cjs",
"types": "./dist/index.d.ts"
}
}
}
Build twice (esbuild makes this painless: one format=esm, one format=cjs). The exports map also encapsulates your package — deep imports of un-exported paths now fail, which is a feature, but announce it as a breaking change.
The interop traps
- A CJS dependency imported from ESM gets only a default export in some cases —
import pkg from "cjs-thing"; const { x } = pkg;is the reliable pattern. __dirnamedoesn't exist in ESM: usenew URL(".", import.meta.url).pathname.- Jest still fights ESM; Vitest doesn't. This pushed us to Vitest and we haven't looked back.
My migration advice
Apps: switch when your framework does (Next, Remix handle it for you). Libraries: ship dual now via exports maps. Internal tools: go pure ESM today and enjoy top-level await. And whatever you do, decide per package, not per file — mixed-mode packages are where the weird bugs live.