Bundling TypeScript in different formats with rollup.js

David Marjanidze
11 min readApr 29, 2023
Rollup.js logo

Table of contents

What is Rollup?

Let’s discuss what is Rollup and how we can use it to transpile and optimize TypeScript builds. Rollup is a JavaScript module bundler. Bundler’s function is to gether all of the source files and compile them into a single file, or if the project is too big, modules can be split and bundled into a few files, and that file(s) can be loaded into the browser or in the Node.js environment. The main idea behind the bundling is that if the browser has requested each file (not bundled) of each library from the server, making separate HTTP requests for each file, would create a lot of internet traffic, thus the site performance would degrade.

Rollup also supports tree-shaking, which implies the process of dead code elimination, strategy to optimize the code. It begins from the entry point and searches for unused/dead code (functions, variables, etc.) in the source and deletes it. As a result reduced bundle is generated, which reduces the time that is needed to send the code to the client’s browser and also reduces the time of the code interpretation, which is done prior to the execution.

One of the main features of Rollup is transforming JavaScript into the different formats. In this article we’re going to discuss only the three formats: ’esm’, ’iife’ and ’cjs’.

ESM stands for “ECMAScript Modules”, which implies that importing something from this kind of module, which the Rollup will generate, can be done with import { Foo } from ‘./foo’ syntax and exporting something from a module can be done with export { Foo } from ‘./foo’. Bundle will look something like this:

class Foo {}

export { Foo }

CJS stands for “CommonJS”, which is basically used in Node.js and implies that importing something from this kind of module which Rollup will generate, can be done with const { Foo } = require(‘./foo’) syntax and exporting something from a module can be done with exports.Foo = Foo. Bundle will look something like this:

'use strict'

class Foo {}

exports.Foo = Foo

IIFE stands for “Immediately Invoked Function Expression”, a function that is executed as soon as it is defined. Rollup wraps your whole library in IIFE. The bundle will look something like this:

var lib = (function (exports) {
'use strict'

class Foo {}
exports.Foo = Foo

return exports
})({})

Rollup can also minify your script with a plugin such as @rollup/plugin-terser. Plugin is a means to change Rollup’s behavior. Code minification reduces load time in the browser and reduces the internet data usage. Minification also increases site speed because it’s less to interpret for the JavaScript interpreter.

Writing a simple library

Now let’s write a simple analytics library to demonstrate how the Rollup can be used to do the stuff which was described above. Also, our library can be executed both in the browser and the Node.js environment. The library will expose the initializeAnalytics function. When it is called in the browser, it will collect some user preferences, such as the navigator.language and the navigator.cookieEnabled. And in case when it is called in the Node.js environment, it will collect process.memoryUsage() value. These two types of data collection serve the demonstration, that it can run in both environments and it can collect certain data based on where it runs. After the data collection, it will send this data with an HTTP POST request.

The project’s file system structure will look like this:

Project’s file system structure

The distfolder contains the build files. The src folder contains all the source code of the library. The package.json contains all the necessary dev dependencies for the Rollup and the overall building process. The rollup.config.ts file contains all the necessary configuration for the Rollup. The tsconfig.json file contains TypeScript configuration. You can download the whole demo from this link https://github.com/davidmarjanidze/rollup-demo.

Let’s start with an HTTP service. Create a file called http.service.ts in the src/services folder:

// src/services/http.service.ts

export class HttpService {
async post(url: string, body: any): Promise<Response> {
return await fetch(url, {
method: 'POST',
body: typeof body === 'object' ? JSON.stringify(body) : body
})
}
}

export function foo(bar: any) {
// This function will not be included in the bundle because it's unused.
console.log(bar)
}

In this file, we are declaring HttpService, which has a single post method to send the data. Bellow the class, the function foo is declared. This is for the purpose of demonstrating tree-shaking, thus it won’t be present in the bundle.

Now let’s implement the analytics service. Create a file called analytics.service.ts in the src/services folder:

// analytics.service.ts

import { HttpService } from './http.service'

const http = new HttpService()

class AnalyticsService {
private collectedData: {
language?: string
cookieEnabled?: boolean
processMemoryUsage?: object
}

async init(): Promise<Response> {
this.collectData()
return await this.sendData()
}

private collectData(): void {
this.collectedData =
typeof window !== 'undefined'
? {
language: navigator?.language,
cookieEnabled: navigator?.cookieEnabled
}
: { processMemoryUsage: process.memoryUsage() }
}

private async sendData(): Promise<Response> {
return await http.post(
'https://jsonplaceholder.typicode.com/posts',
this.collectedData
)
}
}

/**
* @description Activates analytics.
*/
export async function initializeAnalytics(): Promise<Response> {
const analyticsService = new AnalyticsService()
return await analyticsService.init()
}

In this file, we are declaring the AnalyticsService. This service has one public method init, which does two things: 1. As I described above, if the script is being executed in the browser platform, in that case it, collects the user preferences, and if the script runs in the Node.js environment, it collects the process memory usage. This is done by collectData method 2. Then it sends the aforementioned collected data with the sendData method and returns its promise response.

Down bellow the file, we have a single function that instantiates AnalyticsService, calls its init method, and returns its promise response.

Now to declare our export statements, we are going to create index.ts file in src folder, which will serve as an entry point for our library. The entry point is the place from where Rollup will start building a bundle. So whatever the index.ts will export, that will be the public API of the library.

// index.ts

export { initializeAnalytics } from './services/analytics.service'

So as we have a single function that our library clients will need, we export that. It is a good practice not to write such exports that exports everything from the file, e.g. export * from ‘./services/analytics.service’, because at some point file may also export other items, and accidentally we may expose other stuff too that should not be exposed to the public API.

Configuring the TypeScript

TypeScript configuration is done in tsconfig.json. This is the file that the Rollup will look for during the bundling process. Let’s create the tsconfig.json in the root folder of our project:

// tsconfig.json

{
"compilerOptions": {
"module": "ES2022",
"moduleResolution": "classic",
"target": "ES2022"
},
"include": ["src/**/*", "rollup.config.ts"]
}

Here we have a few compiler options. module option specifies which version of the ECMAScript syntax should be used during the compilation. The moduleResolution option specifies how we want our import statements in our source code to be resolved. The target option specifies in which version of the ECMAScript you want your code to be compiled (you may want some of your features to be downleveled to the lower versions to run it in the older environments). The include option specifies which files you want to include in your program with a glob pattern, and we are specifying the src folder, ** includes any folder nested to any level and * includes any file.

Configuring the Rollup

We will need some dependencies, so firstly initialize the package.json by running npm init -y and then running the following command to install the dependencies npm i — save-dev @rollup/plugin-terser @rollup/plugin-typescript @types/node rimraf rollup rollup-plugin-dts.

Now let’s start configuring our build system by creating a file called rollup.config.ts in the project’s root folder.

// rollup.config.ts

import type { InputOptions, OutputOptions, RollupOptions } from 'rollup'

import typescriptPlugin from '@rollup/plugin-typescript'
import terserPlugin from '@rollup/plugin-terser'
import dtsPlugin from 'rollup-plugin-dts'

const outputPath = 'dist/analytics'
const commonInputOptions: InputOptions = {
input: 'src/index.ts',
plugins: [typescriptPlugin()]
}
const iifeCommonOutputOptions: OutputOptions = {
name: 'analytics'
}

const config: RollupOptions[] = [
{
...commonInputOptions,
output: [
{
file: `${outputPath}.esm.js`,
format: 'esm'
}
]
},
{
...commonInputOptions,
output: [
{
...iifeCommonOutputOptions,
file: `${outputPath}.js`,
format: 'iife'
},
{
...iifeCommonOutputOptions,
file: `${outputPath}.min.js`,
format: 'iife',
plugins: [terserPlugin()]
}
]
},
{
...commonInputOptions,
output: [
{
file: `${outputPath}.cjs.js`,
format: 'cjs'
}
]
},
{
...commonInputOptions,
plugins: [commonInputOptions.plugins, dtsPlugin()],
output: [
{
file: `${outputPath}.d.ts`,
format: 'esm'
}
]
}
]

export default config

So we have the main configuration object which, is assigned to the variable named config, and it is necessary that it is exported with export default statement, because Rollup looks for the default exported item. Our configuration consists of an array of objects. Each object represents a different format of the output. Each format has its input and output options.

Every input options include the input and plugins property. In our case, we have the same configuration for all the formats, so it is reused with the commonInputOptions variable in each configuration object. input represents the entry point of the building process, so the Rollup starts with the file that is specified as an input property value, which in our case is src/index.ts. With the plugins property, we can change Rollup’s default behavior at certain points, in our case, we are importing the TypeScript plugin @rollup/plugin-typescript to handle the TypeScript files.

Every output options include file and format options, besides some other properties. The file option specifies the relative path to where we want to write our final bundles, in our case every format lives in the same folder in the root named dist, and every file has the same name analytics, but they all have different suffixes. The format option specifies in which format bundle should be compiled, in our cases these are: esm, iife and cjs. Now iife format has an additional output option — name. The name option specifies the name of the library that will be assigned to the global scope in the browser, and in our case, that is analytics, so the library clients will be able to access it with window.analytics.

The last format is esm, and it uses the rollup-plugin-dts plugin, which is intended to generate TypeScript definition files, in our case that is dist/analytics.d.ts. This file is being used with the source files to attach the types information to it. And in the case of the NPM package, this attachment can be configured in package.json, by adding the types property and referencing to this file ”types”: “dist/analytics.d.ts”. Also, if we want to publish our package on NPM, we have to configure entry points for CJS and ESM. This can be done with main and module properties in the package.json, ”main”: “dist/analytics.cjs.js” and ”module”: “dist/analytics.esm.js”. That means that if our library is installed in the ESM project, it will look for the source in the path that is specified in module, and if our module is installed in the CJS project, it will look for the source in the path that is specified in main property.

Our package.json will look like this:

// package.json

{
"name": "analytics",
"version": "1.0.0",
"main": "dist/analytics.cjs.js",
"module": "dist/analytics.esm.js",
"types": "dist/analytics.d.ts",
"engines": {
"node": "20.0.0"
},
"scripts": {
"build": "rimraf dist && rollup --config rollup.config.ts --configPlugin @rollup/plugin-typescript"
},
"devDependencies": {
"@rollup/plugin-terser": "^0.4.1",
"@rollup/plugin-typescript": "^11.1.0",
"@types/node": "^18.16.3",
"rimraf": "^5.0.0",
"rollup": "^3.21.1",
"rollup-plugin-dts": "^5.3.0"
}
}

Now we can generate the bundles with the npm run build (which is declared in the package.json) command. Now let’s describe how this command works. rimraf is the NPM package which can be used to delete the dist folder, which contains our bundles and so that we can make sure that old bundles are completely deleted. This is a good practice to delete the previous build entirely so that if we change something in the Rollup configuration, old resources do not remain from the previous builds and do not collide with the current ones in some unexpected way. The rollup command uses the — config flag for specifying the configuration file, and the — configPlugin flag is used to specify the @rollup/plugin-typescript plugin to compile the configuration, which is written in the TypeScript. Command output should look like this:

Command output

analytics.esm.js bundle:

class HttpService {
async post(url, body) {
return await fetch(url, {
method: "POST",
body: typeof body === "object" ? JSON.stringify(body) : body,
});
}
}

const http = new HttpService();
class AnalyticsService {
collectedData;
async init() {
this.collectData();
return await this.sendData();
}
collectData() {
this.collectedData =
typeof window !== "undefined"
? {
language: navigator?.language,
cookieEnabled: navigator?.cookieEnabled,
}
: { processMemoryUsage: process.memoryUsage() };
}
async sendData() {
return await http.post("https://jsonplaceholder.typicode.com/posts", this.collectedData);
}
}
/**
* @description Activates analytics.
*/
async function initializeAnalytics() {
const analyticsService = new AnalyticsService();
return await analyticsService.init();
}

export { initializeAnalytics };

analytics.min.js bundle:

var analytics=function(t){"use strict";const a=new class{async post(t,a){return await fetch(t,{method:"POST",body:"object"==typeof a?JSON.stringify(a):a})}};class e{collectedData;async init(){return this.collectData(),await this.sendData()}collectData(){this.collectedData="undefined"!=typeof window?{language:navigator?.language,cookieEnabled:navigator?.cookieEnabled}:{processMemoryUsage:process.memoryUsage()}}async sendData(){return await a.post("https://jsonplaceholder.typicode.com/posts",this.collectedData)}}return t.initializeAnalytics=async function(){const t=new e;return await t.init()},t}({});

analytics.d.ts TypeScript definitions:

/**
* @description Activates analytics.
*/
declare function initializeAnalytics(): Promise<Response>;

export { initializeAnalytics };

Using the builds

When it comes to using these bundles, we have a few choices. The first one is to publish it on NPM, and the usage will look like this:

// Module:

import { initializeAnalytics } from 'analytics'

initializeAnalytics()
.then((response) => response.json())
.then((response) => {
console.log(response)
})
// CJS:

const { initializeAnalytics } = require('analytics')

initializeAnalytics()
.then((response) => response.json())
.then((response) => {
console.log(response)
})

The second option is to load it with the script tag like this:

<html>
<body>
<script src="analytics.min.js"></script>
<script>
analytics
.initializeAnalytics()
.then((response) => response.json())
.then((response) => {
console.log(response)
})
</script>
</body>
</html>

The third option is to copy the contents of the dist folder into the project and configure your tsconfig.json, so that it can use TypeScript definitions (d.ts) files.

In the demo repository https://github.com/datomarjanidze/rollup-demo, you can check the usage-examples folder and there are examples for both the browser and the Node.js usage.

browser.html execution results will look like this:

browser.html execution results (part 1)
browser.html execution results (part 2)

node.js execution results will look like this:

node.js execution results

Final thoughts

So to recap, we’ve described what Rollup is and how it can be used to optimize our builds. We wrote an example analytics library on TypeScript. Then we’ve learned how to do basic TypeScript configuration. After that we have configured the Rollup and package.json. We also looked at how we can prepare the bundles for the different types of usage, and lastly we demonstrated its usage.

Thanks for reading this article. I hope it was helpful. You can follow me on Twitter.

--

--

David Marjanidze

Software Engineer at Klika Tech • Angular, React, AWS, Node.js & Python • Ex TNET, TBC & Ozorix • https://linktr.ee/datomarjanidze