How to publish a hybrid-module npm package

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.

{
  "extends": "./tsconfig.json",
  "compilerOptions": {
    "module": "ESNext",
    "outDir": "./esm"
  }
}
tsconfig.esm.json
{
  "extends": "./tsconfig.json",
  "compilerOptions": {
    "module": "CommonJS",
    "outDir": "./cjs"
  }
}
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 parent package.json lacks a "type" field, or contains "type": "commonjs", .js files are treated as CommonJS. If the volume root is reached and no package.json is found, .js files are treated as CommonJS.
import statements of .js files are treated as ES modules if the nearest parent package.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

NodeJS docs section

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:

exports.foo = bar

// in caller
import {foo} from 'my-package' // nodejs only
Example of CommonJS module supporting named-import from ES module
module.exports = {
    foo: bar
}

// in caller
import {foo} from 'my-package' // ==> NOT WORK
Example of CommonJS not supporting named-import from ES module

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.

Object.defineProperty(exports, '__esModule', { value: true })
This prevents runtime (such as NodeJS) from detecting __esModule as a named-export

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.

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

Buy Me A Coffee