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
.types
at the root is for TypeScript type definitions. A single.d.ts
file can define all exported symbols in your package..main
is for CJS beforeexports
existed. You can emit a single CJS compatible file that can be consumed by (legacy) runtimes..module
is for an ESM entrypoint beforeexports
existed. 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..exports
is 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.json
so the package.json file is also accessible. Theexports
field is supported in modern runtimes. Node has supported it since v16.0.0 - for this reason, you will seeexports
sometimes referenced as node16..exports.*.types
is for TypeScript type definitions. A single.d.ts
file can define all exported symbols in your package for both CJS and ESM..exports.*.import
is 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.*.require
is 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.*.default
is for when a runtime does not match any other condition, and is a fallback. It's also within the spec to specifydefault
as the only entrypoint. I did not usedefault
in 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.json
file in the source code folder, and begins to read theexports
field- 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
exports
field. If the condition is met, the consumer will use the file specified in the
exports
field 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
import
condition first, before trying to fall back todefault
if it exists.