Rolling (up) a multi module system (esm, cjs...) compatible npm library with TypeScript and Babel
Mathias Remshardt
Posted on February 7, 2021
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).
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. 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).
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: To go from TypeScript to JavaScript
- @babel/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:
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 inunbundled
, thebundled
environment transpiles to JavaScript supported by the majority of browsers/node versions. I opted against transpilling completely down toES2015
as older browser can use theumd
alternative when directly importing the library. -
cjs
: Again, the environment is similar toes-unbundled
, with the only difference thatesm
is replaced bycommonjs
with the help of@babel/env
To execute the Babel transpilation, two scripts
have been defined in the package.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 when configured in .babelrc
which is why --source-maps
has been added.
Running the scripts gives the following result:
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 we need to:
- Set the
main
property to ourcjs
index.js
and themodule
property to theesm
index.js
- Set the appropriate properties in
exports
-
require
again to thecjs
index.js
-
import
again to theesm
index.js
-
{
....
"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:
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 thebabel-cli
for the unbundled versions). As we are using babel only for the bundled artifactsbabelHelpers
are set tobundled
, so in case any helpers are needed these are added to the bundle file (you can read more about the property in the documentation). Ininclude
andextensions
the files and their extensions (ts/js
for the example library) to process are defined, whereasexcludes
indicates folders/patterns which should be skipped (just thenode_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:
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:
{
"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
):
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:
{
"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". The setup and link procedure is as follows:
-
Example Library
- Run
npm install
- Run
npm run build
- Run
-
- 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 isnpm link ../node-module-esm
(a more detailed description can be found e.g. here) - 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 ofumd bundled
,esm bundled
andesm unbundled
from the example library giving the following result:
- Use
Posted on February 7, 2021
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.