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"
    }
}

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

Below is not the correct way to publish a package to npm. This is what I thought was correct at the time of Tweeting.
  • .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 before exports existed. You can emit a single CJS compatible file that can be consumed by (legacy) runtimes.
  • .module is for an ESM entrypoint before exports existed. This was mostly used by bundlers like Webpack, and has never been part of any standard. It's superseded by exports, 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. The exports field is supported in modern runtimes. Node has supported it since v16.0.0 - for this reason, you will see exports 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 specify default as the only entrypoint. I did not use default 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"
    }
}

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

I'm still figuring this all out, and I'm not an expert. I'm just trying to share what I have learned so far. If you have any corrections or suggestions, please let me know!

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.

  1. Consumer encounters an import statement

    import {something} from 'my-package';
  2. Consumer resolve the source code for my-package. In Node.js this is done by looking for the folder name in node_modules, and then finding the package.json. In any case, this is up to the consumer to implement

  3. Consumer finds package.json file in the source code folder, and begins to read the exports field

  4. 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.
  5. 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 to default if it exists.