After what feels like years of awkward workarounds and community frustration, TypeScript 4.7 has landed with genuine ES module support for Node.js. The release, which shipped on May 24, introduces the node16 and nodenext module resolution modes, and for those of us who have been wrestling with the ESM/CJS interop nightmare, this is a significant milestone.
The ESM Problem, Briefly#
If you’ve been living in Node.js land, you know the pain. Node.js added support for ES modules (the import/export syntax) starting with version 12, with full unflagged support in Node 16. But TypeScript’s module resolution was designed around CommonJS semantics. The result was a no-man’s-land where you could write ESM syntax in TypeScript, but the compiler would emit CommonJS require() calls, and actual ESM support required a maze of configuration that rarely worked cleanly.
The practical consequences were real. Library authors who wanted to ship both ESM and CJS had to maintain dual build configurations. Consumers trying to import ESM-only packages into TypeScript projects hit cryptic errors. The .mjs extension felt like a hack. And the relationship between "type": "module" in package.json and TypeScript’s module compiler option was, to put it charitably, confusing.
I’ve spent an embarrassing number of hours on projects debugging import issues that ultimately came down to the TypeScript compiler and Node.js having different ideas about how modules should resolve. It was the kind of problem where Stack Overflow had fifty different answers, all of which were correct for slightly different configurations, and none of which worked for yours.
What TypeScript 4.7 Actually Changes#
The new node16 and nodenext moduleResolution options tell TypeScript to follow Node.js’s actual module resolution algorithm, including:
Package.json
"type"field awareness: TypeScript now respects whether a package declares itself as"type": "module"or"type": "commonjs", and adjusts its resolution and emit behavior accordingly..mtsand.ctsextensions: Just as Node.js uses.mjsand.cjsto explicitly mark individual files as ESM or CJS regardless of the package type, TypeScript introduces.mtsand.ctssource file extensions (emitting.mjsand.cjsrespectively).package.jsonexports and imports: TypeScript now understands the"exports"and"imports"fields inpackage.json, which is essential for packages that provide conditional exports for different module systems.Mandatory file extensions in relative imports: When targeting ESM, TypeScript now requires file extensions in relative import paths (
import { foo } from "./bar.js"), matching Node.js’s ESM resolution behavior. Yes, you write.jseven though the source file is.ts— this was controversial, but it’s the correct behavior since TypeScript emits.jsfiles.
Here’s what a minimal tsconfig.json for an ESM Node.js project looks like now:
{
"compilerOptions": {
"module": "node16",
"moduleResolution": "node16",
"target": "es2022",
"outDir": "./dist",
"declaration": true
}
}The Library Author’s Perspective#
For library authors, this release is arguably more important than for application developers. The ability to publish packages with proper "exports" maps that TypeScript actually understands means you can finally ship dual ESM/CJS packages with confidence that consumers on both sides will get correct type resolution.
The package.json "exports" field has been one of the more powerful but underused features in the Node.js ecosystem, largely because TypeScript’s inability to understand it made it a source of type resolution failures. With 4.7, a package can declare:
{
"exports": {
".": {
"import": "./dist/esm/index.js",
"require": "./dist/cjs/index.js"
}
}
}And TypeScript will correctly resolve types for both entry points, assuming the appropriate .d.ts files are co-located with the JavaScript output.
I maintain a few open-source Node.js libraries, and I’ve been holding off on shipping ESM builds precisely because the tooling story was incomplete. With TypeScript 4.7, I’ll be setting up dual builds over the coming weeks. It’s not that it was technically impossible before — but it required enough hacks and workarounds that the maintenance burden wasn’t worth it.
The Rough Edges#
Let me be honest: this isn’t a fairy-tale ending. There are still pain points:
The .js extension in imports is confusing to newcomers. Writing import { handler } from "./utils.js" when the source file is utils.ts violates developer intuition. The TypeScript team’s rationale is sound — TypeScript doesn’t rewrite import paths, and the runtime will need the .js extension — but expect this to generate Stack Overflow questions for years.
The ecosystem is still catching up. Many popular packages don’t have proper "exports" fields yet, and some have "exports" configurations that don’t work correctly with TypeScript’s new resolution. Jest, in particular, has had a rocky relationship with ESM, and adding TypeScript’s new modes into the mix doesn’t make it simpler.
Build tooling fragmentation continues. Between tsc, esbuild, swc, tsx, ts-node, and various bundlers, the matrix of “which tool supports which TypeScript module mode” is getting unwieldy. If you’re using ts-node for development, for example, its ESM support is still experimental and requires the --esm flag plus a loader hook.
My Take#
TypeScript 4.7’s ESM support is overdue but welcome. The TypeScript team made the right call by aligning with Node.js’s actual resolution semantics rather than inventing their own abstraction. It’s going to be painful in the short term — there will be migration headaches, confused developers, and packages that break in unexpected ways.
But this is one of those changes that needed to happen for the Node.js ecosystem to fully embrace ES modules. As long as TypeScript couldn’t properly understand ESM resolution, a huge portion of the Node.js community was effectively locked into CommonJS patterns. Now we can start moving forward.
My advice: don’t rush to migrate existing projects. Wait for the dust to settle, let the ecosystem tools catch up, and start using node16 resolution on new projects. For existing libraries, start planning your dual ESM/CJS builds — your users will thank you. The module wars are finally approaching a ceasefire, and TypeScript 4.7 just brought us significantly closer to peace.
