DEV Community

Cover image for Building an npm package compatible with ESM and CJS in 2024
SnykSec for Snyk

Posted on • Originally published at snyk.io

Building an npm package compatible with ESM and CJS in 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:

{
  "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"
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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"
}
Enter fullscreen mode Exit fullscreen mode

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"
}
Enter fullscreen mode Exit fullscreen mode

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"
}
Enter fullscreen mode Exit fullscreen mode

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.

Top comments (4)

Collapse
 
dimaslanjaka profile image
Dimas Lanjaka

How about binary ?


"bin": {
"clean-nodemodule": "bin/clean-nodemodule",
"clean-nodemodules": "bin/clean-nodemodules",
"del-gradle": "lib/del-gradle.js",
"del-nodemodules": "lib/del-node-modules.js",
"del-ps": "lib/del-ps.js",
"del-yarncaches": "lib/del-yarn-caches.js",
"dev": "bin/dev",
"empty": "bin/empty",
"find-nodemodules": "lib/find-node-modules.js",
"git-fix-encoding": "bin/git-fix-encoding",
"git-fix-encoding-cmd": "bin/git-fix-encoding.cmd",
"git-purge": "lib/git-purge.js",
"git-reduce-size": "bin/git-reduce-size",
"javakill-cmd": "bin/javakill.cmd",
"kill-process": "bin/kill-process",
"nodekill": "bin/nodekill",
"nodekill-cmd": "bin/nodekill.cmd",
"nodekill-ps1": "bin/nodekill.ps1",
"npm-run-series": "lib/npm-run-series.js",
"nrs": "lib/npm-run-series.js",
"prod": "bin/prod",
"rmfind": "bin/rmfind",
"rmpath": "bin/rmpath",
"rmx": "bin/rmx",
"run-s": "lib/npm-run-series.js",
"run-series": "lib/npm-run-series.js",
"submodule": "bin/submodule",
"submodule-install": "bin/submodule-install",
"submodule-remove": "bin/submodule-remove",
"submodule-token": "bin/submodule-token"
}

Collapse
 
rushipatange profile image
rushi patange • Edited

Hi @lirantal,

Thank you for your comprehensive article on package development! As a newcomer to this space, I recently published my first package on npm, @rushipatange/calculate-it. I have a few questions regarding ESM (ECMAScript Modules) support that I hope you can help clarify.

Note: Used node version: 20.17.0

In version 0.0.1 of my package, I encountered an issue when trying to import it into an ESM file (index.mjs):

import { add } from "@rushipatange/calculate-it";
Enter fullscreen mode Exit fullscreen mode

The error I received was:

SyntaxError: Named export 'add' not found. The requested module '@rushipatange/calculate-it' is a CommonJS module...
Enter fullscreen mode Exit fullscreen mode

To resolve this, I modified the ESM build output from .js to .mjs in version 0.0.3, and this change allowed the import to work correctly. However, I'm trying to understand whether it's necessary to use the .mjs extension to ensure ESM support, or if it’s possible to achieve this with just the main, module, and exports fields in the package.json.

package.json for v0.0.1

{
  "name": "@rushipatange/calculate-it",
  "version": "0.0.1",
  "license": "MIT",
  "main": "dist/cjs/index.js",
  "module": "dist/esm/index.js",
  "exports": {
    ".": {
      "import": "./dist/esm/index.js",
      "require": "./dist/cjs/index.js",
      "types": "./dist/esm/index.d.ts"
    }
  },
  "scripts": {
    "build": "rm -rf ./dist && rollup -c rollup.config.mjs"
  },
  "files": [
    "dist/*"
  ],
  "keywords": [
    "calci",
    "calculator"
  ],
  "devDependencies": {
    "rollup": "^4.22.2",
    "rollup-plugin-commonjs": "^10.1.0",
    "rollup-plugin-node-resolve": "^5.2.0",
    "rollup-plugin-typescript2": "^0.36.0",
    "typescript": "^5.6.2"
  },
  "publishConfig": {
    "access": "public"
  }
}

Enter fullscreen mode Exit fullscreen mode

package.json for v0.0.3 ( not v0.0.2 as forgotted to change .js to .mjs which made me too release v0.0.3 🙂)

{
  "name": "@rushipatange/calculate-it",
  "version": "0.0.3",
  "license": "MIT",
  "main": "dist/cjs/index.js",
  "module": "dist/esm/index.mjs",
  "exports": {
    ".": {
      "import": "./dist/esm/index.mjs",
      "require": "./dist/cjs/index.js",
      "types": "./dist/esm/index.d.ts"
    }
  },
  "scripts": {
    "build": "rm -rf ./dist && rollup -c rollup.config.mjs"
  },
  "files": [
    "dist/*"
  ],
  "keywords": [
    "calci",
    "calculator"
  ],
  "devDependencies": {
    "rollup": "^4.22.2",
    "rollup-plugin-commonjs": "^10.1.0",
    "rollup-plugin-node-resolve": "^5.2.0",
    "rollup-plugin-typescript2": "^0.36.0",
    "typescript": "^5.6.2"
  },
  "publishConfig": {
    "access": "public"
  }
}

Enter fullscreen mode Exit fullscreen mode
  1. Is the use of the .mjs extension a requirement for ESM compatibility, or can both ESM and CommonJS modules be served correctly using the exports field without changing file extensions?

  2. Can you explain the role of the main, module, and exports fields in package.json? How do they interact with each other when dealing with ESM and CommonJS?

  3. In your experience, are there best practices or common pitfalls when transitioning between module formats, especially for packages intended for broad use in the community?

I appreciate any insights you can share on these questions. Thank you for your time!

Collapse
 
dirk_luijk profile image
Dirk Luijk

We would you still advocate CommonJS in 2024? Why not publish in ESM format only?

Collapse
 
lirantal profile image
Liran Tal

because you end up starving a lot of upstream consumers who haven't moved their codebase to ESM. Think enterprise installs :-) (but not only)