Rolling (up) a multi module system (esm, cjs...) compatible npm library with TypeScript and Babel
Tags: npm, babel, typescript, javascript
Published: 2021-02-07 16:16:18

---
title: Rolling (up) a multi module system (esm, cjs...) compatible npm library with TypeScript and Babel
published: true
description: Creating a library with Typescript, babel and rollup
tags: npm, babel, typescript, javascript
cover_image: https://images.unsplash.com/photo-1566733622605-eedf4a0f8223?ixid=MXwxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHw%3D&ixlib=rb-1.2.1&auto=format&fit=crop&w=1951&q=80
---

In this article we will delve into the build chain and build steps necessary to create the artifacts required to publish a library on npm. Our goal will be to provide our library consumers with a versatile package supporting (modern/legacy) JavaScript/TypeScript as well as the most common module systems.
What has been written is based on my learnings and research when creating packages and is also meant to be documentation for myself. The process is still in flux, so every feedback (ideas for improvements, critics...) is, as always, very welcome.

# Overview

The first section lists and explains the requirements for the build process as well as the artifacts it produces. Related to this, we will also answer the question if a bundled version is required for each of the supported module systems.  
With the requirements ready, the build chain and, most important, the steps for creating the necessary library artifacts will be laid out.

As demonstration defeats discussion, we will look at the implementation of the sketched out build chain with help of an example "library". In the end there will be a deployment ready package, hopefully fulfilling all listed requirements.  
As our focus lies on packing itself, the "features" of the example library are irrelevant and are therefore kept extremely simple.

The explanations provided are based on my current understanding of the topics and may be opinionated or incomplete (hopefully not wrong). In addition, every package is unique and therefor its/your requirements and the resulting process can differ from what has been written here. However, I have tried to keep the information as overall applicable as possible. As mentioned in the beginning, feed back is very welcome.  
That being said, let's start with the requirements for our build artifacts.

# Requirements

## JavaScript/TypeScript

To me, one important goal was to make the modernly written, not transpilled library code available for further processing. This helps e.g. to decrease bundle sizes, as downstream consumers can base their build chain on the most current/common JavaScript version and only transpile the code to the language level required by their browser- or node version needs.

However, for consumers not able to leverage modern JavaScript, an ES5 based version sacrificing the latest features must be provided.

In case TypeScript is used, a transpilled JavaScript version should be supplied as well, so we do not enforce unnecessary restrictions to consumers by our language choice. "Types" will be provided as separate type definition files.

## Module system

Next to modern JavaScript, the library must support all current/common module systems. At the time of writing these are "ECMAScript Modul" (`esm`), "CommonJs" (`cjs`) and "Asynchronous Module Definition" (`AMD`).

Especially supporting `esm` is important to allow tree shaking support for consumers using bundlers like Rollup or webpack. So even when transpilled to legacy JavaScript, leveraging `esm` is still beneficial (as described [here](https://web.dev/publish-modern-javascript/#modern-with-legacy-fallback-and-esm-bundler-optimizations)).

# To bundle or not to bundle...

Bundling is usually applied when writing JavaScript for the client (e.g. `Single Page Applications`) as it avoids too many round trips to the server (especially before `HTTP/2` arrived) by delivering everything in a single file. However, with multiplexing and server side push now being available in `HTTP/2`, the questions is a bit more controversial today.

If we take into account that downstream build systems further process and bundle the library code, the npm package should contain an unbundled artifact for all supported module systems with the most modern JavaScript version possible. This gives our consumers the flexibility to shape the library code based on their needs (e.g. supported browser versions) helping them to reduce the amount of shipped code by avoiding e.g. unnecessary transpilling.

So if the library code is further processed by downstream consumers, one may ask the question if we need to create a bundled version at all? I sifted through different (popular and not so popular) npm packages and some of these are bundling, while others are not. Also reading blog posts and tutorials did not give a unambiguous answer, leaving me more confused then before.  
Therefor I decided to look at each module system individually combined with whether it is used on the client or server. My hope was that I'd find some enlightenment when narrowing done the question...
Next you find the reasoning I finally came up with.

## ECMAScript Modules

### Browser

When `esm` based library artifacts are consumed by e.g. `SPAs` something like webpack or Rollup should be in place. Further processing, like tree-shaking, bundling, minifying..., is therefor better left to the downstream build process.

So I originally decided not to include a bundled `esm` version. But, when reading about the reasoning for providing a bundled `umd` artifact (described in the section below) I thought about doing the same for `esm`. It does sound counterintuitive at first, I mean what benefit do we get from a modern module system when everything is bundled to a single file. What we do get however, is all the modern JavaScript available for library code written in ES6+ syntax. This means modern browser can choose the bundled `esm` version instead of `umd` for direct import, avoiding all the additional code created to make our library code compatible with previous JavaScript versions. One could argue that in such a case the unbundled artifact could be imported. However, there still could be use cases for the bundled alternative e.g. in case `HTTP/2` is not available and therefor loading a lots of files is not a performant option.

### Node

In case the server application uses a current node version, same reasoning as for the browser applies.  
However, the server can directly load the files from disk which should have almost no performance impact compared to the http request the browser has to perform. So I don't see any reason for using the bundled version here, even if no additional build process is in place.

## CommonJs

### Browser

Same arguments as for `esm`: Bundling should not be required as the imported library is always further processed by downstream build systems.
The only reason why client applications could/should use the `cjs` instead of the `esm` version is in case of an older bundler which does not understand the latter. In all other cases `esm` is the preferred option as the tree shaking support is superior to `cjs`.

### Node

Again no difference to `esm`. However, by including a `cjs` version we ensure older node versions are also supported, so no additional/extra transpilling step is required for library consumers.

## UMD

We will discuss the bundling question for `umd` instead of `amd`, as the latter supports both `amd` and `cjs` in a single artifact.

### Browser

For me, the bundling question was a bit harder to answer for `umd`, as I have most often worked in environments (usually `SPAs`) where either `cjs` and/or `esm` has been used in combination with a dedicated bundler.  
The reason for including a bundled `umd` version is to support direct usage (with no further processing) in (older) browsers e.g. from something like [unpkg](https://unpkg.com). Modern browser, as described above, can use the bundled `esm` version.  
However, when a bundling step is performed downstream, it should always either use `esm` or `cjs` making an unbundled version superfluous.

### Node

Node can always use either `esm` or `cjs`. So in case these are included in the npm package there seems to be no reason to provide a special, unbundled `umd` version for node. It provides no benefit over the bundled variant already considered required in order to cover all use cases.

My final impression regarding `umd` and server applications is, that it makes sense if one wants to include only a single version of the library. However, since npm packages and bundlers (now) support including multiple versions and creating these is not much effort, there seems to be no reason for restricting library consumers to just `umd`.

## Conclusion

This brings us the conclusion that a bundled version is only required for `esm` and `umd`. For all other module system bundling is not a necessity, which finally leads to the following list of library artifacts:

- an unbundled `esm` version
- an bundled `esm` version
- an unbundled `cjs` version
- a bundled `umd` version

These four variants should cover most of our consumers use cases without restricting their build processes and, most importantly, not forcing them to ship unnecessary JavaScript code.

Having the bundle/not bundle question out of the way, we will next define the build chain and its steps to create the listed artifacts.

# Build chain

The diagram below gives an overview of the steps required to go from our written source code (TypeScript for the example library) to the artifacts described in the previous section. The image also shows how the created results are referenced in the `package.json`. This is important as it makes downstream bundlers "aware" of the available versions allowing them to choose the most appropriate one (e.g. `esm` over `cjs` for better tree shaking support).

![Build chain overview](https://github.com/remshams/node-module-esm/blob/images/images/build-chain.jpeg?raw=true)

Diagrams often read kind of abstract before knowing the details and this one is no exception. Therefor, when next going through the process and its artifacts, excerpts from the example library (e.g. configuration files) are referenced to provide additional details.  
One note regarding the employed build tools mentioned in the diagram: I tried to use the most common ones for this/my build chain fulfilling the requirements listed earlier. These can of course be replaced by your own choice e.g. `tsc` instead of `babel` when compiling TypeScript.

## Building the library artifacts

The build steps next described need to get us from our source to the four target build artifacts defined in the previous section. For the example application this means going from TypeScript to `esm` (bundled and unbundled), `cjs` (unbundled) and `umd` (bundled).  
The two main steps required are transpilling and bundling. The latter is of course only needed when the final build artifact is a bundle.

### Transpilling

With the example application written in TypeScript, our first step is to go to the target JavaScript versions. Usually this can either be done by using `tsc` or, as of late, `babel` (with help of the `@babel/typescript` plugin).  
I opted for the latter as it, in my opinion, provides more flexibility compared to `tsc` when configuring the transpilation/compilation step (e.g. `tsc` requires a specific target JavaScript version where as in `babel` it can be defined based on browsers market share, versions and the like). In addition, with the support of TypeScript in Babel, we can now use almost the same build chain for JavaScript or TypeScript projects helping to unify/simplify the process.

The exact Babel configuration is somehow specific for each individual library/project and/or requirements. For the example library we only require two babel plugins:

- [@babel/typescript](https://babeljs.io/docs/en/babel-preset-typescript): To go from TypeScript to JavaScript
- [@babel/env](https://babeljs.io/docs/en/babel-preset-env): To get down to the JavaScript version fulfilling the configuration we opted for (e.g. supported browsers and node versions)

A description of the two plugins and the available configurations is out of scope of the article. Therefor, I only quickly note why a property has been set like that and the reasoning behind it.  
Especially the `@babel/env` plugin provides a lot of flexibility, so in case you are interested in more details the two provided links should make for a good starting point.

Having that said, the configuration for the example library looks like the following:

```JavaScript
const sharedPresets = ['@babel/typescript'];
const shared = {
  ignore: ['src/**/*.spec.ts'],
  presets: sharedPresets
}

module.exports = {
  env: {
    esmUnbundled: shared,
    esmBundled: {
      ...shared,
      presets: [['@babel/env', {
        targets: "> 0.25%, not dead"
      }], ...sharedPresets],
    },
    cjs: {
      ...shared,
      presets: [['@babel/env', {
        modules: 'commonjs'
      }], ...sharedPresets],
    }
  }
}
```

We are using three Babel environments here:

- `esmUnbundled`: The environment only goes from TypeScript to JavaScript and keeps the rest of the code in place. This is on purpose as it makes the most modern version of the library available to our consumers for further processing.
- `esmBundled`: In addition to what is done in `unbundled`, the `bundled` environment transpiles to JavaScript supported by the majority of browsers/node versions. I opted against transpilling completely down to `ES2015` as older browser can use the `umd` alternative when directly importing the library.
- `cjs`: Again, the environment is similar to `es-unbundled`, with the only difference that `esm` is replaced by `commonjs` with the help of `@babel/env`

To execute the Babel transpilation, two `scripts` have been defined in the `package.json`:

```Json
{
  ...
  "build:esm": "cross-env BABEL_ENV=esmUnbundled babel src --extensions '.ts' --out-dir 'lib/esm' --source-maps",
  "build:cjs": "cross-env BABEL_ENV=cjs babel src --extensions '.ts' --out-dir 'lib/cjs' --source-maps"
  ...
}
```

At the time of writing, source maps [seem not to be generated](https://github.com/babel/babel/issues/5261) when configured in `.babelrc` which is why `--source-maps` has been added.  
Running the scripts gives the following result:

![Dist folder with unbundled artifacts](https://github.com/remshams/node-module-esm/blob/images/images/buid-unbundled.png?raw=true)

Unsurprisingly, the `esm` folder contains the unbundled `esm` and `cjs` the unbundled `cjs` artifact.

For the unbundled case we are almost done. What is missing is a reference to our `index.js` entry files from to `package.json` to make Bundlers aware of the available versions.  
As described in detail [here](https://webpack.js.org/guides/package-exports/) we need to:

1. Set the `main` property to our `cjs` `index.js` and the `module` property to the `esm` `index.js`
2. Set the appropriate properties in `exports`
   - `require` again to the `cjs` `index.js`
   - `import` again to the `esm` `index.js`

```Json
{
  ....
  "main": "lib/cjs/index.js",
  "module": "lib/esm/index.js",
  "exports": {
    "require": "./lib/cjs/index.js",
    "import": "./lib/esm/index.js"
  }
  ....
}
```

Having the `package.json` setup like that, Bundlers can now choose whatever alternative is best supported. For example modern ones can take the `esm` artifact whereas as older ones (not supporting the new `module` and `exports` property) fall back to what is referenced in `main`.

To finalize our package we will next look how to generate the bundled artifacts for `esm` and `umd`.

### Bundling

To bundle our library we need a ... Bundler. I chose Rollup for the job since it has good support for creating different versions for each module system from a single entry file. Of course it can again be replaced by whatever Bundler you prefer as long as it bundles to the required module systems and also comes with a plugin for the Transpiler, Terser... of your choice.

As shown in the overview from the beginning of this section, there is not much difference between the build steps of the unbundled and bundled versions:

- the Bundler takes care of orchestrating the build process and build tools (like the Transpiler), so no need to call these "individually"
- an additional bundling step is added to the end of the build chain

For the example library, the Rollup configuration looks like this:

```JavaScript
import babel from '@rollup/plugin-babel';
import resolve from '@rollup/plugin-node-resolve';
import { terser } from "rollup-plugin-terser";

const extensions = ['.js', '.ts' ];

export default  {
  input: 'src/index.ts',
  output: [
    {
      file: 'lib/bundles/bundle.esm.js',
      format: 'esm',
      sourcemap: true
    },
    {
      file: 'lib/bundles/bundle.esm.min.js',
      format: 'esm',
      plugins: [terser()],
      sourcemap: true
    },
    {
      file: 'lib/bundles/bundle.umd.js',
      format: 'umd',
      name: 'myLibrary',
      sourcemap: true
    },
    {
      file: 'lib/bundles/bundle.umd.min.js',
      format: 'umd',
      name: 'myLibrary',
      plugins: [terser()],
      sourcemap: true
    }
  ],
  plugins: [
    resolve({ extensions }),
    babel({ babelHelpers: 'bundled', include: ['src/**/*.ts'], extensions, exclude: './node_modules/**'})
  ]
}
```

There is nothing too fancy going on:  
The `input` property points to the entry `index.ts` and `output` defines the configurations for both `esm` (normal/minified) and `umd`(normal/minified). Additionally, the `sourcemap` attribute has been added and set to `true` to create external source map files. The `name` property for the `umd` version defines the namespace for the exported functions (e.g. `myLibrary.echo()` for the example library).  
For the build itself we require three plugins:

- `@rollup/plugin-node-resolve`: The plugin adds support to resolve imports to other node packages. This is not required for the example library (as no other dependency is used) but has been added since it is not unlikely to occur for more complex packages.
- `@rollup/plugin-babel`: Triggers the transpile step through Babel (basically what we have done by means of the `babel-cli` for the unbundled versions). As we are using babel only for the bundled artifacts `babelHelpers` are set to `bundled`, so in case any helpers are needed these are added to the bundle file (you can read more about the property in the [documentation](https://github.com/rollup/plugins/tree/master/packages/babel#babelhelpers)). In `include` and `extensions` the files and their extensions (`ts/js` for the example library) to process are defined, whereas`excludes` indicates folders/patterns which should be skipped (just the `node_modules` folder for the example library).
- `rollup-plugin-terser`: Used for minification and therefor only added for the minified outputs. This is optional and can be left out in case not wanted or required.

Executing the Rollup process by using the added `package.json` script `build:bundles` produces the following result:

![Dist folder with bundled artifacts](https://github.com/remshams/node-module-esm/blob/images/images/build-bundled.png?raw=true)

An new folder `bundles` has been created containing the `esm` and `umd` artifacts. In contrast to the unbundled ones, there is no need/means to reference the former from the `package.json` as these will be directly imported and are not meant for further processing.

We now have all required "code" artifacts available for the package. The last thing missing is creating type definitions, so that clients using TypeScript can easily integrate the library.

## Types

Babel currently "only" transpiles our TypeScript code to JavaScript. Therefor, as shown in the overview diagram, a dedicated build step is required for creating the type definition files using `tsc`.

As we already have the transpiled JavaScript code, our `tsconfig.json` can be kept pretty simple:

```Json
{
  "compilerOptions": {
    "declaration": true,
    "emitDeclarationOnly": true,
    "declarationMap": true,
    "outDir": "lib/types",
  },
  "include": [
    "./src/index.ts"
  ],
}
```

With the `declarations` and `emitDeclarationOnly` set to `true`, `tsc` only creates declarations files and skips transpilling to JavaScript. The result is then put into the folder defined by `outDir`.  
We also should not miss to create mappings between the `*.d.ts` and `*.ts` files, enabling IDEs like VSCode or IntelliJ to navigate directly to the source instead of the declarations files e.g. on `CMD + click`/`Strg + click` on a method or property name. This is simply done by adding the `declarationMap` to the `tsconfig.json` and setting it again to `true`.

The script `declarations` has been added to the `package.json` to trigger `tsc`, which will create the declaration files in the `types` folder (as defined by `outDir`):

![Dist folder with TypeScript types](https://github.com/remshams/node-module-esm/blob/images/images/build-types.png?raw=true)

As a final step we link the `index.d.ts` file in the `package.json` by means of the `types` property, helping IDEs to discover the types:

```Json
{
  "types": "lib/types/index.d.ts"
}
```

With the unbundled-, bundled library versions and type declarations created, we now have a library ready for being published on npm. Since there are numerous posts out there explaining this final step (and the example application is pretty useless) we will not go further into this.  
So time for wrapping up...

# Conclusion

The goal for this article was to create a versatile build chain to allow creating libraries that:

- provide raw, non transpilled artifacts based on modern JavaScript or TypeScript which can be further processed by downstream build chains
- provide an unbundled- (for consumers using Bundlers) and bundled (for direct usage/import) version
- support all modern and legacy module systems

With the listed requirements ready, we sketched the build steps and setup necessary to create our library artifacts.  
To make the theoretical overview more tangible the process has been described based on a simple example library. This included a possible choice of tools required to realize the build chain and creating the artifacts necessary to fulfill our initial goals.

# Appendix

## Testing locally

To test the example library locally I have created a separate ["testing repository"](https://github.com/remshams/node-module-esm-test). The setup and link procedure is as follows:

- [Example Library](https://github.com/remshams/node-module-esm)
  - Run `npm install`
  - Run `npm run build`
- [Testing Repo](https://github.com/remshams/node-module-esm-test)

  - Use `npm link` to link to the locally available example library e.g. in case both projects are siblings in the folder structure the command is `npm link ../node-module-esm` (a more detailed description can be found e.g. [here](https://medium.com/dailyjs/how-to-use-npm-link-7375b6219557))
  - Run `npm install`
  - Run `npm start` (this starts a local http-server)
  - Open `localhost:8080` in the browser of your choice
  - Navigate to `src`
  - The then opened `index.html` includes imports of `umd bundled`, `esm bundled` and `esm unbundled` from the example library giving the following result:

    ![Screenshot testing index.html](https://github.com/remshams/node-module-esm/blob/images/images/testing-index.png?raw=true)