How to Create a Hybrid NPM Module for ESM and CommonJS.
Tags: javascript, node, npm
Published: 2021-02-12 09:09:50

---
title: How to Create a Hybrid NPM Module for ESM and CommonJS.
published: true
description: How to Create a Hybrid NPM Module for ESM and CommonJS
cover_image: https://www.sensedeep.com/images/blog/npm.jpg
canonical_url: https://www.sensedeep.com/blog/posts/2021/how-to-create-single-source-npm-module.html
tags: javascript, node, npm
---

How can you easily create an NPM module for ESM and CommonJS?

Preferably without using creating two source bases and without needing Webpack?

That has been a vexing question for a while.

Creating an NPM module from single code base that easily targets both CommonJS and ES modules can be an exercise in frustration. Sometimes called a "hybrid" package, easily creating an NPM module that is simply consumed using `import` or `require` is an elusive goal.

On this topic, there are countless blog articles, stack overflow questions and reference pages. These point to various competing strategies, which when tried, work in some situations but are brittle and fail in other situations. Most solutions require either [Webpack](https://webpack.js.org/), [Rollup](https://rollupjs.org/guide/en/), custom scripting and build tools or creating and maintaining a dual source base. And most do not generate efficient, pure ESM code.

When reading [Node documentation](https://nodejs.org/api/), you read about Webpack and Rollup, ESM, CommonJS, UMD and AMD. You read that `.mjs` and `.cjs` extensions are the solution and the future, but it seems that most developers hate them.

You read about the package.json `type = "module"` and `exports` keywords which will magically make everything work, but they don't work as advertised.

>What a god damn mess!!

<!--BREAK-->
<!--more-->

## Creating a Hybrid Module should not be this difficult!

I've tried the `.mjs` and `.cjs` extensions which fail with more than a few essential build tools.

I've tried using bundlers: Webpack and Rollup.

I've tried the package.json `type` field, but it failed when used in combination with the package.json exports map (more below).

I've tried so many approaches, only to find they  fail in one or more use cases.

Finally, I found a solution that is easy, works well and generates efficient ESM code. It supports a single source code base and creates a module that can be consumed by CommonJS and ESM apps and modules.

I don't vouch that this will work in *all* use cases. But it works for all of mine, including consumption by Webpack, the serverless framework, ESM command line tools, and other ESM or CommonJS libraries.

## The problem with .mjs

Before outlining the solution, let me put a sword in a few much touted techniques.

>Why not just use `.mjs` or `.cjs` extensions to indicate ESM or CommonJS code?

Node adopted these source code file extensions to indicate the type of source file. Seems logical at first glance. Extensions typically are used to describe a file type.

This works for simple, stand-alone, non-hybrid use cases. However, if you are building a hybrid module, then using `.mjs` and `.cjs` implies that you either don't have a single code base or you are using or creating custom tooling to copy the source and change the extensions and then patch your source code to use the appropriate extensions in import statements.

ESM code requires that `import` directives specify the path to the imported file. If you import from a URL with `.mjs` that code requires patching to be able to require from a `.cjs` file and vice-versa.

Further, most tool chains do not yet properly support `.mjs` files. And some web servers do not have the `.mjs` extension defined as an 'application/json' mime type. Your favorite bundler may also not understand these files. Consequently, you are writing config and mapping routines or writing custom scripts to manage these files.

I'm yet to find someone who "loves" the `.mjs` and `.cjs` extensions. Fortunately, there are alternatives. Enter the package.json `type` property.

## The problem with the package.json type property

To resolve the problem of whether a file with a `.js` extension is an ES module or CommonJS module, Node invented the package.json `type` property and conventions. If you set the `type` to "module", then all files in that directory and sub-directories are considered to be ESM until either another package.json or node_modules directory is encountered. If you set the `type` to "commonjs", all files are assumed to be CommonJS.

These defaults can be overridden by explicitly naming a file with a `.cjs` or `.mjs` extension.

package.json:
```json
{
    "version": "1.2.3",
    "type": "module"
}
```

This works fairly well but your package is either a "module" or "commonjs" by default. The problem is what happens when you need a package to be a hybrid and export both ESM and CommonJS formats? Unfortunately there is no way to have a conditional type that can be "module" when consumed as ESM and "commonjs" when consumed by CommonJS.

Node does provide a conditional `exports` property that defines the package's export entry points. However, this does not redefine the package type and the `type` and `exports` properties do not combine well.

## The problem with package.json conditional exports

The conditional `exports` property defines a set of entry points. For our purposes, we're interested in the `import` and `require` selectors which enable a hybrid module to define different entry points for use by ESM and CommonJS.

package.json:
```json
{
    "exports": {
        "import": "./dist/mjs/index.js",
        "require": "./dist/cjs/index.js"
    }
}
```

Using tooling (see below), we generate two distributions from a single source code base to target ESM and CommonJS. The `exports` property then directs Node to load the relevant entry point.

However, what happens if we have defined a package with a `type` of module and `exports` for both ESM and CommonJS. All works fine for loading the index.js, but if that file then loads another sub-module (e.g. ./submodule.js), then that file is loaded according to the package.json `type` setting and not the exports setting.

In other words, if a CommonJS app/library used this module to `require` and load from "./dist/cjs/index.js", and the 'index.js' then calls `require('./submodule.js')`, that will fail because the module's package.json had a `type` set to `module` and ESM modules prohibit the use of require.

Unfortunately, if Node loads using the exports.require, it does not assume the code below is CommonJS. It would be ideal if the exports could define a module type to override the top level package.json type.

For example, a hypothetical package.json (don't use, not supported by Node):
```json
{
    "exports": {
        "import": {
            "path": "./dist/mjs/index.js",
            "type": "module"
        },
        "require": {
            "path": "./dist/cjs/index.js",
            "type": "commonjs"
        }
    }
}
```

But this is just a pipe dream.

One more wrinkle, TypeScript does not (yet) behave with exports. So you need to include the legacy `module` and `main` properties for TypeScript. The `main` property points to the CJS entry point and the `module` property points to the ESM entry.

```json
"main": "dist/cjs/index.js",
"module": "dist/mjs/index.js",
```

## The solution

Okay, so what is an approach that works to deliver:

* A single source code base
* Easy building
* Generates native ESM code
* Works with existing tooling
* Generates a hybrid package for either ESM or CommonJS

## Single Source Base

Author your code in ES6, ES-Next or Typescript using import and export.

From this base, you can import either ES modules or CommonJS modules using import. The reverse is not true. If you author in CommonJS you cannot easily consume ES modules.

```javascript

import Shape from './Shape.js'

export class MyShape {
    constructor() {
        this.shape = new Shape()
    }
}
```

Take care when using `export default` and then importing using `require` via CommonJS. The TypeScript or Babel transpilers will automatically bundle exports into a module.exports and then generate a ".default" reference for you when importing, however native NodeJS will not. This means if you are not using a transpiler, you may need to use a `.default` reference. 

```
import Shape from './Shape.js'

const shape = new Shape.default()
```

## Building

Build the source twice, once for ESM and once for CommonJS.

We use Typescript as our transpiler, and author in ES6/ES-Next or Typescript. Alternatively, Babel would work fine for ES6.

Javascript files should have a `.js` extension and not a `.mjs` or `.cjs` extension. Typescript files will have a `.ts` extension.

Here is our package.json build script:

package.json:
```json
{
    "scripts": {
        "build": "rm -fr dist/* && tsc -p tsconfig.json && tsc -p tsconfig-cjs.json && ./fixup"
    }
}
```

The `tsconfig.json` is setup to build for ESM and `tsconfig-cjs.json` builds for CommonJS.

To avoid duplication of settings, we define a shared `tsconfig-base.json` that contains shared build settings used for both ESM and CommonJS builds.

The default `tsconfig.json` is for ESM and builds using "esnext". You can change this to "es2015" or any preset you desire.

tsconfig.json:
```json
{
    "extends": "./tsconfig-base.json",
    "compilerOptions": {
        "module": "esnext",
        "outDir": "dist/mjs",
        "target": "esnext"
    }
}
```

tsconfig-cjs.json:
```json
{
    "extends": "./tsconfig-base.json",
    "compilerOptions": {
        "module": "commonjs",
        "outDir": "dist/cjs",
        "target": "es2015"
    }
}
```

Here is our tsconfig-base.json for ES6 code will all shared settings:

tsconfig-base.json:
```json
{
    "compilerOptions": {
        "allowJs": true,
        "allowSyntheticDefaultImports": true,
        "baseUrl": "src",
        "declaration": true,
        "esModuleInterop": true,
        "inlineSourceMap": false,
        "lib": ["esnext"],
        "listEmittedFiles": false,
        "listFiles": false,
        "moduleResolution": "node",
        "noFallthroughCasesInSwitch": true,
        "pretty": true,
        "resolveJsonModule": true,
        "rootDir": "src",
        "skipLibCheck": true,
        "strict": true,
        "traceResolution": false,
        "types": ["node", "jest"]
    },
    "compileOnSave": false,
    "exclude": ["node_modules", "dist"],
    "include": ["src"]
}
```

## Per ESM/CJS package.json

The last step of the build is a simple `fixup` script that creates per-distribution `package.json` files.
These package.json files define the default package type for the `.dist/*` sub-directories.

fixup:
```sh
cat >dist/cjs/package.json <<!EOF
{
    "type": "commonjs"
}
!EOF

cat >dist/mjs/package.json <<!EOF
{
    "type": "module"
}
!EOF
```

## Package.json

Our package.json does not have a `type` property. Rather, we push that down to the package.json files under the `./dist/*` sub-directories.

We define an `exports` map which defines the entry points for the package: one for ESM and one for CJS. Read more in the [Node Documentation](https://nodejs.org/api) about [conditional exports](https://nodejs.org/api/packages.html#packages_conditional_exports).

Here is a segment of our package.json:

package.json:
```json
"main": "dist/cjs/index.js",
"module": "dist/mjs/index.js",

"exports": {
    ".": {
        "import": "./dist/mjs/index.js",
        "require": "./dist/cjs/index.js"
    }
}
```

## Summary

With the above strategy, modules can be consumed using `import` or `require` by ESM or CommonJS code. And you can use a single code base that uses modern ES6 or Typescript. Users of your ESM distribution get the benefit of increased performance and easier debugging.

We use the approach above for our NPM modules. See the following modules for examples:

* [DynamoDB OneTable](https://www.npmjs.com/package/dynamodb-onetable)
* [OneTable Migrate](https://www.npmjs.com/package/onetable-migrate)
* [OneTable](https://www.npmjs.com/package/onetable-cli)


### Contact

You can contact me (Michael O'Brien) on Twitter at: [@SenseDeepCloud](https://twitter.com/SenseDeepCloud), or [email](mob-pub-18@sensedeep.com) and ready other articles on my [Blog](https://www.sensedeep.com/blog).

To learn more about SenseDeep and how to use our serverless troubleshooter, please visit [https://www.sensedeep.com/](https://www.sensedeep.com/).

### Links

* [SenseDeep Web Site](https://www.sensedeep.com/)
* [SenseDeep App](https://app.sensedeep.com/)
* [GitHub OneTable](https://github.com/sensedeep/dynamodb-onetable)
* [NPM OneTable](https://www.npmjs.com/package/dynamodb-onetable)