How to Create a Hybrid NPM Module for ESM and CommonJS.

  1. Single Source Base
  2. Building
    1. For Web browsers packages
  3. Per ESM/CJS package.json
  4. Package.json
  5. Summary
  6. Links:

Single Source Base

Write your code in ES modules using import and export.

1
2
3
4
5
6
7
import Shape from './Shape.js'

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

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, Typescript files will have a .ts extension.

Here is our package.json build script:

package.json:

1
2
3
4
5
{
"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:

1
2
3
4
5
6
7
8
{
"extends": "./tsconfig-base.json",
"compilerOptions": {
"module": "esnext",
"outDir": "dist/mjs",
"target": "esnext"
}
}

tsconfig-cjs.json:

1
2
3
4
5
6
7
8
{
"extends": "./tsconfig-base.json",
"compilerOptions": {
"module": "commonjs",
"outDir": "dist/cjs",
"target": "es2015"
}
}

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

tsconfig-base.json:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
{
"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"]
}

For Web browsers packages

Note: If you are building packages for web browsers, you might need to generate UMD module as well. Unfortunately, TypeScript’s UMD output does not support creating a browser global. You would better use an external bundler such as rollup.js.

1
npm install rollup rollup-plugin-typescript2 --save-dev

rollup.config.js

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
import typescript from 'rollup-plugin-typescript2';
import uglify from "@lopatnov/rollup-plugin-uglify";

export default [
{
input: 'src/index.ts',
output: [
{
file: 'dist/umd/index.js',
format: 'umd',
name: 'MyLibGlobalName'
},
{
file: 'dist/mjs/index.js',
format: 'es',
},
{
file: 'dist/cjs/index.js',
format: 'cjs',
},
],
plugins: [typescript()]
},
];

In this case, also you need to change package.json:

1
2
3
"scripts": {
"build": "rollup --config && ./fixup"
},

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:

1
2
3
4
5
6
7
8
9
10
11
cat >dist/cjs/package.json <<!EOF
{
"type": "commonjs"
}
!EOF

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

Alternatively, you can put this logic into a .js file, such as fixup.js:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
import fs from 'fs';
// If you are using CommonJS
// const fs = require('fs');

const content1 = `{
"type": "commonjs"
}
`;

const content2 = `{
"type": "module"
}
`;

try {
fs.writeFileSync('./build/cjs/package.json', content1);
// file written successfully
} catch (err) {
console.error(err);
}

try {
fs.writeFileSync('./build/mjs/package.json', content2);
// file written successfully
} catch (err) {
console.error(err);
}

Then change the scripts part of your package.json:

1
2
3
"scripts": {
"build": "rollup --config && node ./fixup.js"
},

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.

Here is a segment of our package.json:

package.json:

1
2
3
4
5
6
7
8
"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. 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.