Living through the ESM transition
Name
esm-transition — living through the esm transition
Synopsis
Eight years of shipping packages across the CommonJS and ES Module line, the mistakes authors still make, and whether it was worth it.
Filed
· 1,291 words · 12 min
Description
I have shipped JavaScript packages for the whole ES Module transition. Not the start of the spec, but the start of the pain: the years when every package needed two builds, every tool had its own idea of resolution, and every issue tracker had a thread titled ERR_REQUIRE_ESM.
A lot of my packages exist because of that transition. Some of them helped. Some of them were workarounds that I hoped would die, and most of them did. This is what I learned from living on both sides of the line.
#The timeline, from my side
The dates are from my own commit logs. They are not the official history, but they show how long "almost there" lasted.
- 2018:
dimport. Browsers could parseimportandexportinside<script type="module">, but not all of them supported dynamicimport().dimportwas a polyfill for ES Module syntax "in any browser, even IE". It checked for nativeimport()first and did nothing if it did not have to. - 2020: named exports and
"exports"maps. In August,dequaldropped its default export for a named one, and then it got an"exports"map the next day.kleurgot its"module"entry and an"exports"map that June. The changes were small, but each one was a major version. - 2020: uvu learns native ESM. Until November, the uvu README told you to run
uvu -r esm, which loaded theesmpackage to transpile your imports at runtime. Native ESM support came in a pull request, and then a long tail of fixes forrequirehooks and file paths. - 2021:
resolve.exports. In January, I wrote a resolver for the"exports"field because every bundler was writing its own. - 2021:
tsm. In October, I wrote a TypeScript loader that worked asnode <file>, as a--requirehook, and as an ESM--loader. All three were necessary at the time. - 2022–2023:
"types"conditions. TypeScript learned to read"exports", and every package had to learn to tell it the truth.clsx2.0 was a breaking release for this reason alone.
That is five years of steady work on one problem. And all of it is just my side of it.
#Why I wrote a resolver
The "exports" field is a good idea. A package says which files are public, and which file to load under which conditions: import, require, browser, node, worker, and so on. Everything else is private.
The problem was that Node had the only correct implementation, and it was not something you could import. So webpack wrote one, Rollup's resolver wrote one, esbuild wrote one, Jest wrote one, and they did not agree. The condition order was different. The fallbacks were different. One tool would pick "browser" before "import", the next would do it the other way, and your package would work in one and break in the other.
The README for resolve.exports says it better than I can now:
If we, as a community, begin propagating different dialects of the resolution algorithm, then we're headed for deep trouble.
So I wrote one small, correct resolver with no file system access. You give it the package.json contents and an entry, and it gives you the paths:
import * as resolve from "resolve.exports";
resolve.exports(pkg, "foobar/lite");
//=> ["./lite/module.mjs"]
resolve.exports(pkg, "foobar/lite", { require: true });
//=> ["./lite/require.js"]
It did not stop every tool from writing its own. It did give them something to test against. That was the goal: not to win, but to give everyone one shared answer.
#Why I wrote a loader
tsm came from a simpler frustration. In 2021, running a TypeScript file in Node meant choosing between tools that each supported one module system well and the other one badly. You needed a different tool, or a different flag, for require, for --loader, and for a plain node file.ts.
tsm was esbuild with three entry points:
$ tsm server.ts
$ node -r tsm server.tsx
$ node --loader tsm main.jsx
The ESM loader API was still experimental, and it changed more than once under me. Today Node strips types by itself, and most of tsm's reason to exist is gone. That is the best outcome a workaround can have.
#The mistakes authors still make
These are the mistakes I see most often in other packages. I made almost all of them in my own packages first.
One .d.ts for two formats. If your "import" and "require" conditions point to different files but share one index.d.ts, TypeScript has to guess which format that file describes. It guesses from the file extension and your "type" field. Here is kleur today:
".": {
"types": "./index.d.ts",
"import": "./index.mjs",
"require": "./index.js"
}
That declaration file describes CommonJS, so it is wrong for ESM consumers in some setups. clsx fixed this in 2.0 with a declaration file for each condition:
".": {
"import": { "types": "./clsx.d.mts", "default": "./dist/clsx.mjs" },
"default": { "types": "./clsx.d.ts", "default": "./dist/clsx.js" }
}
It is more verbose, but it is correct.
"types" is not first. Conditions match in object order, and the first match wins. If "import" comes before "types", TypeScript resolves to the JavaScript file and never sees your types.
No ./package.json export. An "exports" map hides every file you do not list. That includes package.json, which some tools still read directly. dequal lists it so those tools keep working.
Default exports across the boundary. A CommonJS module.exports = fn and an ESM export default fn do not mean the same thing to every consumer. Some people get the function. Others get { default: fn }. Named exports avoid this, which is why dequal, clsx, and most of my packages switched to them.
Adding "exports" in a minor release. An "exports" map makes every unlisted file private. If anyone imported your-pkg/lib/thing.js, you just broke them. Adding the map is a breaking change, even if no code changed.
The dual-package hazard. If one app loads both your ESM and your CJS build, it has two copies of your module. That means two copies of any module state, two copies of every class, and an instanceof check that fails for no visible reason. Stateless packages do not care. Packages with caches, registries, or classes do.
Missing extensions. In Node's ESM, import './utils' does not work. You need ./utils.js. Bundlers allow it, so the mistake hides until someone runs your source directly.
#What changed: require(esm)
The biggest change came late. Node now lets CommonJS require() a synchronous ES Module. It is on by default in current releases, and it was backported to the older LTS lines.
This removes the main reason for dual packages. A CommonJS consumer can now load your ESM-only package, as long as your module graph has no top-level await. The dual-package hazard goes away, because there is only one copy.
For my own new packages, I now ship ESM only.
#Was it worth it?
Yes. But it cost more than anyone planned.
What we got is real. Browsers, Node, Deno, Bun, and Cloudflare Workers all run the same module format. Imports are static, so tools can see the whole graph before running it. Tree shaking works because of that. Top-level await works. A file I write today can run in five runtimes with no build step, and that would have sounded like a joke in 2015.
What it cost was most of a decade of double builds, conflicting resolvers, broken types, and confused users. The cost did not fall on the people who designed the transition. It fell on package authors, who had to support two formats at once, and on users, who got error messages that blamed the wrong package.
I wrote a lot of small tools to get through it. dimport, tsm, and resolve.exports were all bridges. I am happy that most of them are now less necessary. A bridge that nobody needs anymore did its job.
If you are still on the other side, see Maintaining 200 packages alone for how I schedule breaking changes like these, and The packages nobody knows they install for why every one of those majors was more work than it looked.