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"
}
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.
Top comments (4)
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"
}
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.
In version 0.0.1 of my package, I encountered an issue when trying to import it into an ESM file (index.mjs):
The error I received was:
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 themain
,module
, andexports
fields in thepackage.json
.Is the use of the
.mjs
extension a requirement for ESM compatibility, or can both ESM and CommonJS modules be served correctly using theexports
field without changing file extensions?Can you explain the role of the
main
,module
, andexports
fields inpackage.json
? How do they interact with each other when dealing with ESM and CommonJS?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!
We would you still advocate CommonJS in 2024? Why not publish in ESM format only?
because you end up starving a lot of upstream consumers who haven't moved their codebase to ESM. Think enterprise installs :-) (but not only)