WTF, ESM!?
Preface
Right now, it's extradordinarly clear we are experiencing growing pains in our great migration to ECMAScript Modules. Below is the part of my package.json that I posted.
{
"type": "module",
"main": "./dist/index.cjs",
"module": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js",
"require": "./dist/index.cjs"
},
"./package.json": "./package.json"
}
}{
"type": "module",
"main": "./dist/index.cjs",
"module": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js",
"require": "./dist/index.cjs"
},
"./package.json": "./package.json"
}
}As mentioned above, I made some mistakes here. First of all, it's important to diffrentiate between what is runtime code that engines will understand (what is JavaScript), and what is type definitions (what is TypeScript). This (seems) easy enough, we can see clearly that there are two types fields. One is under the . entrypoint for exports, the other is at the root. Let's break it down.
Where did I go wrong?
It's pretty hard to get a conclusive answer from the "crowd" of JavaScript developers about the best way to publish a package to npm. Everyone has conflicting answers & we all seem to be following what already exists on GitHub and npm. There are lots of packages that are published technically incorrectly but used and installed by millions of people. This means a lot of packages follow what I'm calling a colloquial standard. Here's what I *thought to be true*, and so do most other devs...
Warning
.typesat the root is for TypeScript type definitions. A single.d.tsfile can define all exported symbols in your package..mainis for CJS beforeexportsexisted. You can emit a single CJS compatible file that can be consumed by (legacy) runtimes..moduleis for an ESM entrypoint beforeexportsexisted. This was mostly used by bundlers like Webpack, and has never been part of any standard. It's superseded byexports, but it might be good to keep in order to support the older bundlers..exportsis the new standard for defining entrypoints for your package. It is a map of entrypoints to files. The.entrypoint is the default entrypoint. We also include./package.jsonso the package.json file is also accessible. Theexportsfield is supported in modern runtimes. Node has supported it since v16.0.0 - for this reason, you will seeexportssometimes referenced as node16..exports.*.typesis for TypeScript type definitions. A single.d.tsfile can define all exported symbols in your package for both CJS and ESM..exports.*.importis for ESM. This is the entrypoint for how a modern runtime should import your package when running under CommonJS. It is a single ESM compatible file..exports.*.requireis for CJS. This is the entrypoint for how a modern runtime should import your package when running under CommonJS. It is a single CJS compatible file..exports.*.defaultis for when a runtime does not match any other condition, and is a fallback. It's also within the spec to specifydefaultas the only entrypoint. I did not usedefaultin my initial Tweet.
I made a few mistakes here. First of all, types are specific to ESM and CJS. This means there should be two types fields. One for ESM, one for CJS. Even the TypeScript documentation gets this wrong, and is something they're working on updating. Solutions for this are also pretty wild. I've managed to get things working by simply copying ./dist/index.d.ts to ./dist/index.d.cts after bundling, and making the following changes to my package.json.
{
"exports": {
".": {
"import": {
"types": "./dist/index.d.ts",
"default": "./dist/index.js"
},
"require": {
"types": "./dist/index.d.cts",
"default": "./dist/index.cjs"
}
},
"./package.json": "./package.json"
}
}{
"exports": {
".": {
"import": {
"types": "./dist/index.d.ts",
"default": "./dist/index.js"
},
"require": {
"types": "./dist/index.d.cts",
"default": "./dist/index.cjs"
}
},
"./package.json": "./package.json"
}
}Note that we point to a .js file and not .mjs when targeting ESM. This is because our package.json has type set to module. This tells our runtime that all files are assumed to be ESM unless they have a .cjs extension. There's no such thing as an ESM package, only ESM files. Using "type": "module", is just a way to tell the runtime to interpret existing files as ESM.
What gives?
Note
Clearly, this is messy. It's messy because we're trying to support a lot of different runtimes, and we're trying to support them all at once. We're trying to support ESM, CJS, legacy bundlers, modern bundlers, and TypeScript. We're trying to support all of these runtimes at once, and finally, we're trying to support them all at once in a single package.json file. Few other languages suffer from this level of complexity and fragmentation.
Let's break down the mess and why all these things are the way they are. Starting off with exports.
exports is the modern way to define what your package exports. We have already established that it is a map of entrypoints to files. Let's step through what happens when a runtime/consumer (we'll use the word consumer, because TypeScript - which is not a runtime - is also reading our code in this case) wants to import our package.
Consumer encounters an import statement
import {something} from 'my-package';import {something} from 'my-package';Consumer resolve the source code for
my-package. In Node.js this is done by looking for the folder name innode_modules, and then finding thepackage.json. In any case, this is up to the consumer to implementConsumer finds
package.jsonfile in the source code folder, and begins to read theexportsfield- It steps through each field (in order, despite it being an object) and checks if the condition the consumer is looking for exists in the
exportsfield. If the condition is met, the consumer will use the file specified in the
exportsfield as the entrypoint for the package. If the condition is not met, it will continue to the next field. If no condition is met, a consumer will usually exit/throw an error.An example of a condition being met could be Node.js looking for an ESM file. In this case, it would look for the
importcondition first, before trying to fall back todefaultif it exists.