Building an npm package compatible with ESM and CJS in 2024

Written by:
wordpress-sync/blog-feature-multithreading

April 18, 2024

0 mins read

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 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:

1{
2  "name": "math-add",
3  "version": "1.2.0",
4  "description": "",
5  "module": "src/index.mjs",
6  "type": "module",
7  "scripts": {
8    "test": "echo \"Error: no test specified\" && exit 1",
9  },
10  "keywords": [],
11  "author": "",
12  "license": "Apache-2.0"
13}

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:

1node:internal/modules/esm/resolve:205
2  const resolvedOption = FSLegacyMainResolve(packageJsonUrlString, packageConfig.main, baseStringified);
3                         ^
4
5Error: 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:

1{
2  "name": "math-add",
3  "version": "1.0.0",
4  "description": "",
5  "main": "src/index.cjs",
6  "module": "src/index.mjs",
7  "scripts": {
8    "test": "echo \"Error: no test specified\" && exit 1",
9  },
10  "keywords": [],
11  "author": "",
12  "license": "Apache-2.0"
13}

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:

1{
2  "name": "math-add",
3  "version": "1.5.0",
4  "description": "",
5  "exports": {
6    ".": {
7      "require": "./src/index.cjs",
8      "import": "./src/index.mjs"
9    }
10  },
11  "scripts": {
12    "test": "echo \"Error: no test specified\" && exit 1",
13  },
14  "keywords": [],
15  "author": "",
16  "license": "Apache-2.0"
17}

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:

1{
2  "name": "math-add",
3  "version": "1.5.0",
4  "description": "",
5 "main": "./dist/index.js",
6 "module": "./dist/index.mjs",
7 "types": "./dist/index.d.ts",
8  "exports": {
9    ".": {
10      "require": "./dist/index.js",
11      "import": "./dist/index.mjs",
12      "types": "./dist/index.d.ts"
13    }
14  },
15  "scripts": {
16    "test": "echo \"Error: no test specified\" && exit 1",
17    "build": "tsup src/index.ts --format cjs,esm --dts --clean",
18    "watch": "npm run build -- --watch src",
19    "prepublishOnly": "npm run build"
20  },
21  "keywords": [],
22  "author": "",
23  "license": "Apache-2.0"
24}

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.

Patch Logo SegmentPatch Logo SegmentPatch Logo SegmentPatch Logo SegmentPatch Logo SegmentPatch Logo SegmentPatch Logo SegmentPatch Logo SegmentPatch Logo SegmentPatch Logo SegmentPatch Logo SegmentPatch Logo SegmentPatch Logo Segment

Snyk is a developer security platform. Integrating directly into development tools, workflows, and automation pipelines, Snyk makes it easy for teams to find, prioritize, and fix security vulnerabilities in code, dependencies, containers, and infrastructure as code. Supported by industry-leading application and security intelligence, Snyk puts security expertise in any developer’s toolkit.

Start freeBook a live demo

© 2024 Snyk Limited
Registered in England and Wales

logo-devseccon