Building an npm package compatible with ESM and CJS in 2024 | Snyk (2024)

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:2052 const resolvedOption = FSLegacyMainResolve(packageJsonUrlString, packageConfig.main, baseStringified);3 ^45Error: 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.

Keep your open source dependencies secure

Snyk provides one-click fix PRs for vulnerable open source dependencies and their transitive dependencies.

Start free with GithubStart free with Google

Building an npm package compatible with ESM and CJS in 2024 | Snyk (2024)
Top Articles
Latest Posts
Article information

Author: Annamae Dooley

Last Updated:

Views: 6465

Rating: 4.4 / 5 (65 voted)

Reviews: 88% of readers found this page helpful

Author information

Name: Annamae Dooley

Birthday: 2001-07-26

Address: 9687 Tambra Meadow, Bradleyhaven, TN 53219

Phone: +9316045904039

Job: Future Coordinator

Hobby: Archery, Couponing, Poi, Kite flying, Knitting, Rappelling, Baseball

Introduction: My name is Annamae Dooley, I am a witty, quaint, lovely, clever, rich, sparkling, powerful person who loves writing and wants to share my knowledge and understanding with you.