How to bundle a tree-shakable typescript library for web with tsup and publish with npm

·

10 min read

Thinking of the vast frontend ecosystem, for the most part, I don’t need to implement stuff on my own. The majority of the time, I google a particular problem and most of the time I find a library or stack-overflow answer, Bingo. But there are times when I find myself lacking something unique, whether it is a company task or a hackathon project, a holistic approach, a whole new sexy vibe is needed indeed.

Recently I was trying to get my head around sports analytics and realized that like the majority of the data science ecosystem, all tools are written in python. While I was re-studying to sharpen my rusted python skills (no pun intended 🦀), I also tried to do some stuff with JS for fun as well, which turned into to a separate library based on D3. In this blog post, I won’t explain the library itself because that's another story (P.S watch this space). But this time I'll share a very basic setup to enable you to come up with your own with a minimum of fuss. Github link at the end 👇

For a JS/TS library you would need a transpiler/bundler, a module strategy, a published version of your lib, and optionally a demonstration app.

Bundler (esbuild & tsup)

esbuild claimed to be the fastest in the town when it comes to bundlers, and it seems so according to benchmarks as well. Similar to Parcel, tsup is a zero-config library(in case you don't want it) based on esbuild. Parcel is based on Rust based swc compiler meanwhile esbuild is based on Go. Tsup is tailored to work seamlessly with TS. Even though it is zero config you can change the configs if need be, so we do. Here is the config I used for this article.

tsup.config.ts

import type { Options } from 'tsup';

const env = process.env.NODE_ENV;

export const tsup: Options = {
  splitting: true,
  clean: true, // clean up the dist folder
  dts: true, // generate dts files
  format: ['cjs', 'esm'], // generate cjs and esm files
  minify: env === 'production',
  bundle: env === 'production',
  skipNodeModulesBundle: true,
  entryPoints: ['src/index.ts'],
  watch: env === 'development',
  target: 'es2020',
  outDir: env === 'production' ? 'dist' : 'lib',
  entry: ['src/**/*.ts'], //include all files under src
};

Here we are transpiling all our TS files into JS with the help of tsup's config. Afterwards we will publish it with npm in JS format with type declarations so that both JS and TS apps can use it.

I need to clarify a few things though.

splitting

This is flagged an experimental feature that only works for esm right now. You need to specify outDir as well. With this feature, we are enabling tree-shaking so that users don't have to download shared bits of code again and again when they switch between different pages for example. Look here for further info.

skipNodeModulesBundle

Skips building dependencies for node modules, and use them as they are imported. As a library builder, you can even bundle and serve your dependencies but this is way beyond the scope of this article.

dts

Generates typescript declaration files (e.g. index.d.ts), useful when you consume libraries with typescript.

entry

See how we enable all files to be parsed by tsup using a simple regex pattern ['src/**/*.ts']

Rest of the options pretty much does the stuff, as the names imply

Module Strategy

Handling modules has been a pain in the neck for JS community for a long while. Therefore, there are some different strategies when you want to distribute your JS bundles. 4 major modules around are:

  1. CommonJS
  2. AMD: Async Module Definition
  3. UMD: Universal Module Definition
  4. ES modules

I won't get into the details of AMD and UMD in this post. But I'd like to talk about CJS and ESM which are the most common ones. There is a distinct difference between CommonJS and ES modules which is Tree shaking. CommonJS modules are the original way to package JavaScript code for Node.js. Up-to-date versions of node.js support both. The major difference here is in a CommonJS module all of your pretty js files are bundled into a single file. You'll remember the syntax if you've worked on both ends.

ESM (ES modules)

import {john} from "doe";

export jane;

CJS (CommonJS)

const john = require("doe")

module.exports = jane;

Tree-shaking

Tree-shaking is eliminating dead code and unused exports from your bundle. With CJS even if you only going to use a simple Card component let's say, if it is not tree-shakable library, you need to download the whole library and add it to your vendor chunk eventually. For example one of the most used utility libraries around is lodash and there has been a lot of problem because of its huge bundle size in the past If you use es modules import/exports which are tree-shakable by its nature, bundlers can detect unused exports. Dead codes are eliminated that way.

You can output different module formats with tsup but cjs file won't be tree-shakable. Don't forget you are just making the library tree-shakable. It is the app that uses the library actually shakes and eliminates the dead code. But in this article, I want to output two different formats for two different platforms (web & node) the former one would be tree-shakeable. The main motivation behind it was creating a UI library for me but you never know which platform your library is going to end with if it is not a UI library particularly. So why not provide it in both formats just for sake of doing it 🃏 Below line will handle that for us.

format: ['cjs', 'esm']

We will also change the package.json a bit.

  "main": "lib/index.cjs",
  "module": "lib/index.js",
  "types": "lib/index.d.ts",

The module field is used to determine the ES version of the package. Also, we want to mark our library as an es module. To do so, we'll add the below line to package.json. "type": "module", which affects tsup compilation as well in terms of file extensions.

├── index.js          # esm
└── index.cjs         # cjs

index.js is an es module now. The reason behind all this is for a UI library we want its components/classes to be importable separately so that the user can import a Component like the one below in an async manner.

image.png

script type="module" lets the browser treat this piece of import as a module

Output Directories

If you ever investigated libraries like Ant Design you'll recognize that there will be a separate lib folder inside of the package, from which you can import particular components from. We would have both lib and dist libraries. dist would stand for bundle version of your files and possibly could be used when you want to distribute your library over a CDN for example.

Used and Unused Exports 🤔

Now try to look at this implementation the other way around.

Let's assume an app has our library as a dependency and this app use webpack or a similar bundler that is ready to tree-shake our library. It can detect unused exported modules thanks to a feature called usedExports

But if you bundle all your js into a single whole bundle will be imported anyway. There is another concept called sideEffect pops out at this stage. According to webpack

A "side effect" is defined as code that performs a special behavior when imported, other than exposing one or more exports. An example of this are polyfills, which affect the global scope and usually do not provide an export.

Webpack can identify if it should run this optimization by checking a custom field called sideEffects inside package.json. Preserving module structure and serving library with tiny modules enable bundlers to eliminate the dead code. If you have a particular module that you want to be bundled whether it is used or not, you should add it to the sideEffect list. This includes scss or other files as well. A possible sideEffects configuration that you can add to your package.json is

  "sideEffects": [
    "dist/*",
    "lib/**/style/*",
    "*.scss"
  ],

In the above example, you're telling webpack not to clean modules considered to be dead code and this makes sense because under a dist folder possibly you have a single minified bundle including all needed utility functions, etc. It can be even a CJS module in which tree-shaking is not possible. The same is true for your style files, you possibly need all of them, it might be misleading to tell webpack to skip the whole module/subtree of scss.

How to test if sideEffectsactually works

Basically looking at the bundle. Let's assume a very basic library

Screen Shot 2022-09-08 at 23.38.40.png

index.ts

import { sum } from './Sum';

const sampleJson = [
  {
    id: 'f5e7457f-467d-4e37-9652-f2fb1b51c712',
    first_name: 'John',
    last_name: 'Doe',
  },
];

export { sampleJson, sum };

and inside Sum we are just exporting a basic sum method Sum/index.ts

const sum = (a: number, b: number) => a + b;

export { sum };

After we transpile with tsup all the files will be added to the bundle. Note that we are preserving folder structure under lib.

Screen Shot 2022-09-08 at 23.40.33.png

Let's jump into to the app and locally link the library for testing. I'll use this webpack starter github.com/wbkd/webpack-starter

The easiest way to test your library is to register it locally with npm link or yarn link.

yarn link v1.22.19
success Registered "tsup-library-template".
info You can now run `npm link "tsup-library-template"` in the projects where you want to use this package and it will be used instead.

Then go to the app you want to use the library then yarn link <your-library>

yarn link v1.22.19
success Using linked package for "tsup-library-template".
✨  Done in 0.02s.

This is the quickest way to set you up. If you want to manage your lib and examples in the same monorepo have a look at the yarn workspaces

Let's use the library

Inside the app we will use library like this

someApp.js

import {sum} from 'tsup-library-template';

console.log(sum);

even though library has two expected members we only used one method which is sum and that reflects on webpack bundler.

someAppsBundle.js

 !function(){"use strict";console.log(((o,c)=>o+c))}();
//# sourceMappingURL=app.5712c325.js.map

Now import the sampleJson and use it as well

import {sum, sampleJson} from 'tsup-library-template';

console.log(sum, sampleJson);

Look at the bundle generated by the webpack it is larger because it know includes two imported members sum and sampleJson.

!function(){"use strict";console.log(((e,f)=>e+f),[{id:"f5e7457f-467d-4e37-9652-f2fb1b51c712",first_name:"John",last_name:"Doe"}])}();
//# sourceMappingURL=app.b0051b82.js.map

We can even solely import a module inside using the below syntax

import {sum} from 'tsup-library-template/lib/Sum';

console.log(sum);

output will be same with the first example.

!function(){"use strict";console.log(((o,c)=>o+c))}();
//# sourceMappingURL=app.5712c325.js.map

Some of you might think at this stage "isn't it just regular module import/export". Yes, you are absolutely right, the only difference here is we let the bundler to do that inside of an npm package. We're done with the building, time to send it to the npm 🚀

Versioning and Releasing

npm version major  // increase major version

npm version minor  // increase minor version

npm version patch // increase patch version

All of the above comments set the new version, updates the package json and commits to git

npm login

to login your npm account, then run

npm publish

on terminal. Check you npm account to see package is ready with the version you specify inpackage.json

Summary

tsup is a bundler based on arguably the fastest bundler: esbuild. You don't have to modify configs, but you can compose the way you want for different outputs. ESM is the preferred module strategy for web and UI libraries. The main difference between a CJS and ES module is that the latter is tree-shakable makes it more efficient and requires less time to download, parse and execute for browsers. We provide the library with type declarations in JS format. Add sideEffects:false to your package.json for further optimization when other bundlers use the library. Quickly test with npm link

Further Reading

I've found all of these resources very useful. Shout out to all of them 🙌

In case you want to use this approach as a template 👉 github.com/orabazu/tsup-library-template

Webpack Error

If you see this error when you use the library

ModuleNotFoundError: Module not found: Error: Can't resolve './Sum' in '/Users/hunor/workspace-zhunor/js/tsup-template.js/lib'
Did you mean 'index.js'?
BREAKING CHANGE: The request './Sum' failed to resolve only because it was resolved as fully specified

By default webpack expects you to provide the file extension when importing a module in .mjs files or any other .js files when their nearest parent package.json file contains a "type" field with a value of "module", otherwise webpack would fail the compiling with a Module not found error.

In order to skip this you might need to set fullySpecified: false in webpack configuration similar to below.

{
  test: /\.m?js/,
  resolve: {
    fullySpecified: false
  }
}