How to publish a hybrid-module npm package
This post shows the best practices to create a hybrid npm package, which supports both commonjs and ES module types.
Background
CommonJS module:
const {myMethod} = require('my-package')
ES module:
import {myMethod} from 'my-package'
How?
I will show how to make your package my-package
support both types of import. In the package directory:
TLDR: check the sample package
Step 1: Configure conditional exports
In package.json
, add the following fields:
{
"main": "cjs/index.js",
"type": "module",
"exports": {
".": {
"import": "esm/index.js",
"require": "cjs/index.js"
}
}
}
Note 1: you can optionally add "module": "esm/index.js"
to support legacy tools.
Note 2: We mark the package as ES module, and redirect the CommonJS request to a special location via conditional exports. You can do the reverse, by marking the package as CommonJS and redirect the ES Module request to a special location via conditional exports.
Step 2: Package implementation
In the package source code, add .js
extension to all relative import statements. For example:
Before:
import {foo} from './bar'
After:
import {foo} from './bar.js'
Note: ES module requires extension for relative import. CommonJS does not require extension, but it also allows importing (requiring) with extension.
Note: Even if you using Typescript and the bar
module is implemented in bar.ts
, always use import './bar.js'
(not ./bar.ts
).
Step 3: Build multiple targets
Create dual builds and output them in two directories esm/
, cjs/
with associated entries esm/index.js
, cjs/index.js
.
For example: If you are using Typescript, create 2 tsconfig files tsconfig.esm.json
, tsconfig.cjs.json
and compile the package separately with tsc -p tsconfig.esm.json && tsc -p tsconfig.cjs.json
.
Note: You will need to emit two separate type definitions for the two builds.
Step 4: (important) Make the ES entry work
This step is very important and is usually missed in many online tutorials.
Create cjs/package.json
with the following content:
{
"type": "commonjs"
}
Why is this step required?
Conditional exports control where to search for the module when you specify import 'my-package'
based on the caller module's module type. import 'my-package'
becomes:
require('node_modules/my-package/cjs/index.js')
if the caller package is CommonJS.import 'node_modules/my-package/esm/index.js'
if the caller package is ES module.
If you skip step 4, and the caller is an ES module, esm/index.js
is imported. Its module type is determined by the nearest package.json
. Because there is no esm/package.json
, esm/index.js
is loaded as CommonJS module determined by package.json
in the package root directory, and fail to parse ES module syntax.
Quote from NodeJS docs:
If the nearest parentpackage.json
lacks a"type"
field, or contains"type": "commonjs"
,.js
files are treated as CommonJS. If the volume root is reached and nopackage.json
is found,.js
files are treated as CommonJS.import
statements of.js
files are treated as ES modules if the nearest parentpackage.json
contains"type": "module"
.
Regardless of the value of the"type"
field,.mjs
files are always treated as ES modules and.cjs
files are always treated as CommonJS.
Why any other approaches will not work?
Deoptimized solution 1: use extension
You can omit the previously introduced step 4 and use .mjs
extension for ES module build, and .cjs
extension for CommonJS build.
But you will additionally have to add .mjs
all relative imports for ESM build, and .js
or .cjs
or no extension (.mjs
is not allowed) for CommonJS build. This is not supported well by existing tools, such as tsc
: https://github.com/microsoft/TypeScript/issues/18442
Note: type definition for foo.mjs
/foo.cjs
are foo.d.mts
/foo.d.cts
, respectively.
Note: this solution is recommended if your package has only a single file. In build, you can use mv
command to rename the file extension.
If you are trying to publish your package in CommonJS only and want it to be used in both CommonJS and ES module types. Read the next section:
Interoperability
CommonJS imports an ES module package
A CommonJS module can only import an ES module package asynchronously, by using await import('my-package')
. Synchronous import is not possible.
The resolved value of the import()
call is the same as if it is called from an ES module, with the default
export is the value associated with 'default'
key.
ES module imports a CommonJS package
This is where all the clutter comes from.
An ES module can import a CommonJS module package synchronously like other packages with import 'my-package'
. However, because CommonJS does not have any concept about default import, there are some caveats mostly coming from the imperfect (and theoretically, impossible to be perfect) hacks provided by compilers/runtimes targeting making CommonJS modules like ES module.
The caller ES module is not transpiled to CommonJS
This falls back to the runtime behavior such as NodeJS, browser.
By default, only default import is supported.
In the callee CommonJS package, values are exported through the module.exports
or exports
objects. For example: exports.foo = bar
or module.exports = {foo: bar}
. In the caller ES module, with import myPackage from 'my-package'
, myPackage
value becomes the exports
object defined in the callee package, or, equivalently const myPackage = require('my-package')
in CommonJS syntax.
Basically, it is impossible to do named import from a CommonJS package. If you try import {foo} from 'my-package'
, in most cases, there will be an error:
SyntaxError: Named export 'foo' not found. The requested module 'my-package' is a CommonJS module, which may not support all module.exports as named exports.
CommonJS modules can always be imported via the default export, for example using:
import pkg from 'my-package';
const {foo} = pkg;
at ModuleJob._instantiate (node:internal/modules/esm/module_job:122:21)
at async ModuleJob.run (node:internal/modules/esm/module_job:188:5)
Node.js v20.2.0
NodeJS statical analysis
If the callee package is written in a way it can be statically analyzed, NodeJS allows named importing it from v14.13.0 (Released on Sep 30, 2020)
See below examples:
Note: this does not mean that the same code can be run on browsers.
The caller ES module is transpiled to CommonJS
If the caller written in ES module is transpiled to CommonJS, it is natural to transpile named imports which imports CommonJS module. Most compilers provide advanced support to the default importing a CommonJS module.
The expectation is to make import foo from 'foo'
to be transpiled to const foo = require('foo')
. This feature is called module interop.
- Babel enables module interop by default.
- Typescript disable module interop by default. You need to explicitly enable it via the ---esModuleInterop flag.
import foo from' foo'
is transpiled to
- When module interop is disabled:
const foo = require('foo').default
- When module interop is enabled:
const foo = require('foo') && require('foo').__esmodule ? require('foo').default : require('foo')
Whenever a compile transpiles a module from ES module to CommonJS, it always enable s__esModule
regardless of whether module interop is enabled.
Note that, if one disables module interop, the transpiled code will never behave the same as if the code is kept in ES module. So, the rule of thumb is always enable the module interop flag. It is hard to understand why Typescript disables this feature by default.
Because of this, you should assume that the caller always have module interop flag enabled. But note that if there is a default import to a CommonJS module which is originally written in ES module (i.e., with __esModule enabled), the behavior of the caller module will be different as if it is kept as ES module and as if it is transpiled to CommonJS.
So that, if you provide a hybrid build, if the caller is kepts as ES module, usually its package.json has "type" be "module", and the bundler/runtime will look for the ES module entry when importing a package.
The tricky way
There is a tricky way to make default-importing a CommonJS module consistent regardless of whether the caller is transpiled to ES Module or CommonJS with or without es module interop: assign module.exports
to the default export value and assign its 'default'
key with the default export itself.
module.exports = foo
foo.default = foo
foo.otherNamedExport = bar
This trick, htowever, modifies the semantics value of the exported value, and disables named import in NodeJS. In browser, named import is always impossible.
Final Recommendation
In conclusion, to survive in ES module/CommonJS chaos, there are several working solutions:
- If you are using Typescript, always enable the --esModuleInterop flag.
For package maintainer
Solution 1: CommonJS only
Merit:
- Easy to maintain.
Demerit:
- Will be legacy some days.
- The transpiled code is difficult to look.
- Default export requires hacky assignment to
module.exports
which usually requires manual modification on transpiled code. - Without hacky export, there will be inconsistency in the code as if the package is used as a CommonJS package (
import foo from 'my-package'; foo = foo.default
) and as an ES module package (import foo from 'my-package'
). Compilers usually support converting the latter to the former if the caller's target is CommonJS.
Solution 2: (recommended) hybrid
Merit:
- Consistent syntax for both CommonJS and ES module callers.
Demerit:
- High maintenance cost to maintain dual builds.
- All dependencies must support CommonJS unless the dependencies are bundled together.
Solution 3: ES module only
Merit:
- Low maintenance cost
Demerit:
- For CommonJS caller, must use asynchronous
import()
call to import.
For project
ES module can import both CommonJS and ES module directly, and also the new standard. I highly recommend using ES module for your new projects.
How to do? Following this gist: https://gist.github.com/sindresorhus/a39789f98801d908bbc7ff3ecc99d99c#how-can-i-move-my-commonjs-project-to-esm
and the official docs from NodeJS: https://nodejs.org/api/esm.html#differences-between-es-modules-and-commonjs
Screenshot:
If your project (is written in ES module) uses a package which provides CommonJS only, you will have to convert all named to a default import + assignment. For example: with React, which does not support ESM natively (July 24, 2023)
You cannot do: import {useState} from 'react'
.
You must do: import React from 'react'; const {useState} = React
.
Related post: nodejs Dual package hazard: https://nodejs.org/api/packages.html#dual-commonjses-module-packages