How to bundle a npm package with TypeScript and Rollup

Paleo
5 min readMar 17, 2018

--

Make your work clean and neat

Why to bundle a npm package?

For cleanliness.

When a developer writes the code of a npm package, he distributes his code over several ES6 modules for convenience. But then, the only valid exported members of the npm package are those of the entry point.

Exported members from internal modules, unless the author knowingly uses this capability, should not be accessible from outside the package. Indeed, they are not guaranteed by the version system. And, in fact, a simple refactoring could break an external code that would call them.

Let’s imagine the following module intended for internal use in a npm package:

// internal-log.mjs
export function info(message) {
// ... write the message somewhere ...
}

We would like to avoid that a code, from outside the package, allows itself a:

import { info } from "my-npm-package/internal-log"
info("Hi, there!")

A npm package therefore deserves to be built. Rollup is ideal to do this job. And it is sufficient for a npm package written in JavaScript. Also, I invite the JavaScript developer to stop reading this article and go to the Rollup documentation.

The case of a npm package written in TypeScript creates additional complexity. The typing should follow the same way as the JavaScript code, because an internal type shouldn’t be accessible from the outside of the npm package. But there is no tool like Rollup to bundle types written in TypeScript (however, it will exist). Doing things properly then requires a little organization and some elbow grease. That is what this article is about.

The file structure of our project

Here is the file structure I use:

my-npm-package/
├─ build/
│ ├─ make-bundle.js
├─ src/
│ ├─ exported-definitions.d.ts
│ ├─ index.ts
│ ├─ ... other TS files here ...
├─ .gitignore
├─ .npmignore
├─ package.json
├─ tsconfig.json

You will find here a complete example in real situation.

The different files listed here are detailed in the following sections.

The npm package configuration : “package.json

First, you need a “package.json” file:

npm init

Then, install the necessary dependencies for the build :

npm install -D rollup typescript uglify-es npm-run-all rimraf

Edit the “package.json” file and define the following entries :

  "main": "my-npm-package.min.js",
"types": "my-npm-package.d.ts",

The main file of the npm package is the JavaScript bundle that will be produced. Similarly, the typings exported by the package will all be combined in a single my-npm-package.d.ts file.

Add the following scripts :

"scripts": {
"_clear": "rimraf build/compiled/*",
"_tsc": "tsc",
"_make-bundle": "node build/make-bundle",
"build": "run-s _clear _tsc _make-bundle",
"watch": "tsc --watch"
},

The _clear command is implemented with the package rimraf, it removes the intermediate JavaScript files from the previous build. This cleaning is not mandatory but it helps to keep more clarity.

The _tsc command is used to launch the TypeScript compiler.

The _make-bundle command executes a script, which runs Rollup and then produces the my-npm-package.d.tsfile.

The build command sequentially runs the three previous commands using the run-s utility provided by the npm-run-all package.

The TypeScript compiler configuration : “tsconfig.json

We will need the following compilation options:

{
"compilerOptions": {
"target": "es2017",
"moduleResolution": "node",
"outDir": "build/compiled",
"declaration": true
},
"include": [
"src"
]
}

We generate ES2017 code with the standard keywords import and export. The target directory is build/compiled/and the source files are in src/. The compiler will produce definition files using the declaration option.

Organize the TypeScript source code to help generate the bundle of types

The subtlety is to gather in separate files all the exports intended for an external use.

If the interfaces and types are not too numerous, then group them in an exported-definitions.d.ts file. You can also distribute them on several files, the main thing is to not mix types usable by the outside with internal types.

Watch out for re-exports! A bug in the TypeScript compiler prevents the augmentation of re-exported types. The type augmentation is sometimes helpful so drop re-exports of types.

The implementation should follow the same principle: do not mix externally usable classes and functions with those for internal use.

Automate build with the “make-bundle.js” script

Here is the complete code:

const { promisify } = require("util")
const fs = require("fs")
const path = require("path")
const rollup = require("rollup")
const uglifyEs = require("uglify-es")
const readFile = promisify(fs.readFile)
const writeFile = promisify(fs.writeFile)
const packageName = "my-npm-package"
const srcPath = path.join(__dirname, "..", "src")
const compiledPath = path.join(__dirname, "compiled")
const distNpmPath = path.join(__dirname, "..")
async function build() {
let bundle = await rollup.rollup({
input: path.join(compiledPath, "index.js")
})
let { code } = await bundle.generate({
format: "cjs",
sourcemap: false
})
let minified = uglifyEs.minify(code)
if (minified.error)
throw minified.error
await writeFile(path.join(distNpmPath, `${packageName}.min.js`), minified.code)
await writeFile(path.join(distNpmPath, `${packageName}.d.ts`), await makeDefinitionsCode())
}
async function makeDefinitionsCode() {
let defs = [
"// -- Usage definitions --",
removeLocalImportsExports((await readFile(path.join(srcPath, "exported-definitions.d.ts"), "utf-8")).trim()),
"// -- Driver definitions --",
removeLocalImportsExports((await readFile(path.join(srcPath, "driver-definitions.d.ts"), "utf-8")).trim()),
"// -- Entry point definition --",
removeSemicolons(
removeLocalImportsExports((await readFile(path.join(compiledPath, "index.d.ts"), "utf-8")).trim()),
)
]
return defs.join("\n\n")
}
function removeLocalImportsExports(code) {
let localImportExport = /^\s*(import|export) .* from "\.\/.*"\s*;?\s*$/
return code.split("\n").filter(line => {
return !localImportExport.test(line)
}).join("\n").trim()
}
function removeSemicolons(code) {
return code.replace(/;/g, "")
}
build().then(() => {
console.log("done")
}, err => console.log(err.message, err.stack))

In this script, we let Rollup work on the JavaScript code previously compiled by TypeScript. I also made the choice to minify the bundle in order to pass the desire to possible future contributors to directly modify the JavaScript code.

In a second step, we build our “bundle of types”. This is simple processing: the content of the definition files is loaded (makeDefinitionsCode function) and filtered to remove local module imports and exports (removeLocalImportsExports function). Indeed, the latter are no longer necessary since all the types concerned will be combined in the same file. Then the result is concatenated.

The definition files that are concatenated are of two kinds: the exported-definitions.d.ts file contains the types to export we ourselves have gathered. And the index.d.ts file, generated by the TypeScript compiler, corresponds to the implementation file containing functions and classes to export.

The “.gitignore“ and “.npmignore“ files

The contents of the .gitignore file:

node_modules
/build/compiled
/my-npm-package.d.ts
/my-npm-package.min.js

The contents of the .npmignore file:

/build/compiled

Our generated bundles will not be commited, but they will be well published by a npm publish.

Run build

Execute the command:

npm run build

… and check the two bundles of JavaScript code and TypeScript types at the root of the package.

It’s done.

--

--