Building an npm package compatible with ESM and CJS in 2024
Tags: engineering, opensourcesecurity, javascript, node
Published: 2024-04-19 02:00:18

Publishing JavaScript packages that are compatible with both ECMAScript Modules (ESM) and CommonJS (CJS) is a critical skill for developers who aim to integrate wide-ranging libraries.


This write-up focuses on practical approaches and best practices for maintaining ESM and CJS support. We'll examine the implications of avoiding the `”type: module”` declaration in dual-compatible libraries and investigate the use of the main and module fields in `package.json` to differentiate entry points.


The article also clarifies the purpose and application of the `exports` field in the `package.json` file, which is essential for controlling module resolution. Additionally, for TypeScript projects, we'll explore the integration of package manifest exports with a module type, ensuring both compatibility and type safety.


You are encouraged to follow along the step-by-step code sections below, but there’s a GitHub code repository named [package-json-exports](https://github.com/snyk-snippets/package-json-exports) for full reproduction examples that relies on the open source npm proxy project *Verdaccio*.


Avoid defining “Type: Module” for libraries that support ESM and CJS
--------------------------------------------------------------------


Did you know when you omit the `”type”` field in the `package.json` file, it is implicitly set to `commonjs` by default? Such as: `”type”: “commonjs”`.


The moment you add `”type”: “module”` into the `package.json` file, you explicitly set the library to target only ESM projects. You still have to define a `main` field in the package manifest and that would then be updated to reflect the exported ESM-compatible module.


To provide a practical example, the following `package.json` file definition doesn’t work for upstream ESM consumers even though it is “pure” ESM:



```
{
  "name": "math-add",
  "version": "1.2.0",
  "description": "",
  "module": "src/index.mjs",
  "type": "module",
  "scripts": {
    "test": "echo \"Error: no test specified\" && exit 1",
  },
  "keywords": [],
  "author": "",
  "license": "Apache-2.0"
}
```

The `module` definition is an ESM module and the `type` clearly defines this library to target ESM consumers, but it is missing the `main` field in the `package.json` file. You’ll see Node.js throwing an exception with an error about not being able to locate the package, such as:



```
node:internal/modules/esm/resolve:205
  const resolvedOption = FSLegacyMainResolve(packageJsonUrlString, packageConfig.main, baseStringified);
                         ^

Error: Cannot find package '/~/package-json-exports/consumer-esm/node_modules/math-add/package.json' imported from /~/package-json-exports/consumer-esm/server.js
```

In conclusion, avoid using a `”type”: “module”` declaration in the package manifest for an npm package.


Let’s unfold the use of `main` and `module` fields in the `package.json` file and how they are better directives.


ESM and CJS compatibility with `main` and `module` fields
---------------------------------------------------------


Before the days of ESM, the `main` field in the `package.json` file was designed to tell the Node.js runtime what is the entry point for the package. Usually, developers would have an `index.js` or an `app.js` file in the root directory and would set the `main` field to point to it, such as `”main”: “index.js”`.


If you omit the `main` field in the package.json file, the Node.js runtime will try to resolve the entry to the package via a file convention for `server.js` in the package's root directory.


To define an npm package to be compatible with both ESM and CJS we can use a convention in which the `main` points to a CJS export and the `module` points to an ESM export.


Library:



```
{
  "name": "math-add",
  "version": "1.0.0",
  "description": "",
  "main": "src/index.cjs",
  "module": "src/index.mjs",
  "scripts": {
    "test": "echo \"Error: no test specified\" && exit 1",
  },
  "keywords": [],
  "author": "",
  "license": "Apache-2.0"
}
```

Consumers upstream can be both CJS and ESM projects. CJS projects will consume the `src/index.cjs` file and ESM projects will consume the `src/index.mjs` file. Both types of consumers upstream don’t need to specify anything special about the `math-add` dependency, it will “just work”.


Understanding package.json exports field
----------------------------------------


The use of the `exports` field in the `package.json` file provides even more granular control over which constructs are exported from your npm package and the way in which they are consumed.


For example, you can provide the full path to the entry file if a Node.js runtime tries to require your npm package with `require(‘math-add’)` and a whole different file as an entry if the Node.js runtime tries to load the package with `import .. from ‘math-add’)`.


Here is a code example for a dual-mode CJS and ESM package as described:



```
{
  "name": "math-add",
  "version": "1.5.0",
  "description": "",
  "exports": {
    ".": {
      "require": "./src/index.cjs",
      "import": "./src/index.mjs"
    }
  },
  "scripts": {
    "test": "echo \"Error: no test specified\" && exit 1",
  },
  "keywords": [],
  "author": "",
  "license": "Apache-2.0"
}
```

Package.json exports and a module type for a TypeScript project
---------------------------------------------------------------


If you are writing your package ESM code with TypeScript and want to maintain CJS backward compatibility, you’d also want to declare *types* and need to work out TypeScript compilation and transpiling for the CJS part.


For the TypeScript compilation and bundling job, I recommend `tsup`. You’ll then need to have a `build` scripts stage — and don’t forget to run that build before a CI job or a manual invocation of the npm package publishing process.


Here is a complete example:



```
{
  "name": "math-add",
  "version": "1.5.0",
  "description": "",
 "main": "./dist/index.js",
 "module": "./dist/index.mjs",
 "types": "./dist/index.d.ts",
  "exports": {
    ".": {
      "require": "./dist/index.js",
      "import": "./dist/index.mjs",
      "types": "./dist/index.d.ts"
    }
  },
  "scripts": {
    "test": "echo \"Error: no test specified\" && exit 1",
    "build": "tsup src/index.ts --format cjs,esm --dts --clean",
    "watch": "npm run build -- --watch src",
    "prepublishOnly": "npm run build"
  },
  "keywords": [],
  "author": "",
  "license": "Apache-2.0"
}
```

You’ll notice that we also use the new Node.js runtime support for watching for changes with the `--watch src` command-line flag. In the past, this would have been achieved by `nodemon`, which is a great package, but fewer dependencies are better.


Nex steps: Modern npm package publishing and structure in 2024
--------------------------------------------------------------


This was a short and focused write-up for JavaScript developers with straightforward, actionable insights for handling module formats effectively in their projects.


You also want to make sure that you are following best practices for publishing npm packages and creating modern npm packages that go into more depth on TypeScript setup, tests, CI, security, and other considerations.