TypeScript library tips: Rollup your types!

“feels good” to have all your .d.ts being shipped as one, private API free, with smallest possible footprint

- This article is a part of TypeScript library tips series

- 📖 Always free version of this article can be found on my Github

When authoring a TypeScript library make sure to follow best industry practices like shipping smallest possible files by rollup-ing your javascript code. As a not very known technique apply this pattern also on your ambient types definitions, that are generated by TypeScript compiler — bundling them into 1 single .d.ts file, which will also mitigate misuse of your possible leaking private APIs.

If you wanna go into more deep dive, please continue reading ↓↓↓.

If you’re writing a JavaScript library or application, you’ll definitely need some kind of bundler. There are various options on the market, most familiar are probably webpack and rollup. Webpack is mostly used for apps and rollup for libraries although although both can be used for the opposite thing. Because we’re talking about shipping libraries lets focus on Rollup.

NOTE: this article will not go through how to configure Rollup or how it works in detail

Rollup.js helps us bundle our library into 1 file!

Which in practice means following transformation:

// Raw source:src/
├── components
│ ├── counter.tsx
│ └── greeter.tsx
├── index.ts
├── math.ts
└── types.ts
↓↓↓// Bundled via Rollup:dist/
├── index.js

This gives our consumers following benefits ✅:

  • less kB transferred via wire
  • faster install time
  • faster bundling time in apps (webpack/rollup has to process only 1 files instead of N)
  • faster tests execution (cold starts can be painfully slow especially if you’re using jest and your app is massive)
  • better tree-shaking

Now, what about types ? You’ve might noticed that our contrived library is written in TypeScript. So what kind of types are we talking about ?

Type declaration files (.d.ts)

To to leverage TypeScript to full extent, you definitely wanna ship type declaration files to your consumers, so no matter if they use TypeScript or not, their editor/CI can give them excellent DX thanks to your library type declarations (typings).

NOTE: declaration files end with *.d.ts suffix. That d stands for "declaration"

By default, TypeScript wont emit declaration files.

To emit those, you’ll need to enable following config within your tsconfig.json

{
"compilerOptions": {
// enabling declaration (.d.ts) emit
"declaration": true,


// optional - in general it's a good practice to decouple declaration files from your actual transpiled JavaScript files
"declarationDir": "dist/dts",

// optional if you're using babel to transpile TS -> JS
"emitDeclarationOnly": true
}
}

With our tsconfig set, if we execute rollup (which needs to be configured as well via plugins to be able to process TypeScript), or just run raw tsc binary, we'll get following file tree within our dist directory:

dist/
// (our runtime/source code bundled to 1 file via rollup!)
├── index.js
// (type declaration files emitted by tsc)
├─ dts
├── components
│ ├── counter.d.ts
│ ├── greeter.d.ts
├── index.d.ts
├── math.d.ts
├── types.d.ts

What happened here? Our whole library tree structure is mirrored by emitted declaration .d.ts files.

While we have optimized runtime by rollup-ing our source code files into 1, we have quite messy .d.ts output that might inflict various issues like:

  • 🚨 breaks encapsulation, because consumers can use/access parts of our codebase, that are not part of public API surface (which will not work if we rollup-ed our runtime anyways -> thus will throw runtime error 💣)
  • 🚨 sending unnecessary baggage (kB) over wire
  • 🚨 slower TypeScript type-check/processing time (if you don’t use skipLibCheck:true)

Side Note: Breaks encapsulation ?

Lets elaborate more on this one.

Following image is a showcase of our library contrived example:

- On the left side, we have 3 TypeScript files, amongst which logger.ts contains private API parts that are not exposed via barrel (index.ts).

- On the right side we can see emitted .d.tsfiles. It's obvious that math.d.ts declaration file doesn't contain any private API from logger. Which is all good. but...

Our library source code on left and generated .d.ts files on the right

On next image, we have a contrived application code on the left, which consumes our library.

Now although there is no mention of logger within math.d.ts (as expected), we still ship logger.d.ts, thus consumers of our library are free to access it - thus breaks encapsulation. Unfortunately TypeScript is not guarding us from this kind of situation.

Consumer App has access to our library private APIs

How can we fix those ? Rollup time! ⚡️

Rollup your type declarations

Where we are right now:

dist/
// (our runtime/source code bundled to 1 file via rollup!)
├── index.js
// (type declaration files)
├─ dts
├── components
│ ├── counter.d.ts
│ ├── greeter.d.ts
├── index.d.ts
├── math.d.ts
├── types.d.ts

Where we wanna go:

dist/
├─ index.js // (our runtime/source code bundled to 1 file via rollup!)
├─ index.d.ts // (rollup-ed type declaration files)

How we’ll get there?

Unfortunately TypeScript doesn’t provide such a functionality so we have following options:

  • not use declaration emit at all and maintain that file manually

- I don’t recommend this solution.

- Why? Hard to maintain, it will get out of sync rather quickly

  • use some OSS tools

Let’s use existing tooling shall we? While there is plethora of tooling in the wild, we’ll focus on 2 particular libraries:

  • rollup-plugin-dts
  • api-extractor

rollup-plugin-dts

Project link: https://github.com/Swatinem/rollup-plugin-dts

How does it work:

After your TypeScript files are transpiled to JavaScript and rollup-ed into 1 file, you’ll need to add another processing pipeline, by invoking dts

ROLLUP-PLUGIN-DTS workflow: from rolluping .ts source to .js and emitting .d.ts files to final .d.ts rolluping into one index.d.ts

Configuration:

// @file: rollup.config.js
import dts from 'rollup-plugin-dts'
const config = [
// your default rollup config for transpilation and bundling
// ...
{
// path to your declaration files root
input: './dist/dts/index.d.ts',
output: [{ file: 'dist/index.d.ts', format: 'es' }],
plugins: [dts()],
},
]
export default config

Run it:

# do standard JS bundling and creating .d.ts rollupyarn rollup

Done ✅ !

Pros:

  • if you already use rollup for you library, setting this up takes less than 30 secondes
  • blazing fast
  • follows similar transformation methods like rollup (unfortunately it doesn’t use same technique like mangling files and exporting via alias by its original name to save another kB) -> if you have multiple named exports they are aggregated into single export {} instead having multiple export TokenName statements.

Cons:

  • works only with rollup

api-extractor

Project link: https://api-extractor.com

How does it work:

API-EXTRACTOR workflow: from rolluping .ts source to .js and emitting .d.ts files, to final .d.ts rolluping into one index.d.ts with optional 2 outputs.

Api-extractor comes with extended feature set beyond just rollup-ing declaration files to one, like creating nice Markdown API file report and Document Model in JSON format which can be leveraged to generate full blown documentation for your project!

For the brevity sake of this article we gonna focus only on rolluping capabilities.

Configuration:

By default, api-extractor will create all 3 things mentioned previously (rollup dts, api.md and .json model), so we’ll need to turn off those features explicitly.

// @file api-extractor.json
{
"mainEntryPointFilePath": "<projectFolder>/dist/dts/index.d.ts",
"apiReport": {
// turn off
"enabled": false
},
"docModel": {
// turn off
"enabled": false
},
"dtsRollup": {
// Whether to generate the .d.ts rollup file.
"enabled": true,
// Where we wanna create our .d.ts rollup
"untrimmedFilePath": "<projectFolder>/dist/index.d.ts"
},

"messages": {
// turn off various warnings, that might not be useful right now
// check the official docs for more!
"extractorMessageReporting": {
"default": {
"logLevel": "none"
},
"ae-forgotten-export": {
"logLevel": "none"
}
}
}
}

Run it:

Now all we need to do is to invoke tsc or rollup and after that's done, we'll execute api-extractor binary:

yarn rollup # OR yarn tscyarn api-extractor run --local

Done ✅ !

Pros:

  • independent CLI
  • build-in powerful capabilities for generating documentation
  • rich set of configuration options for creating dts rollup (shipping declaration types for various release stages of your library like alpha/beta/etc…)

Cons:

  • no namedexport stattements aggregation
  • manual plumbing into existing tools needed (I actually prefer that but from perspective of added overhead I mark this one as con)
  • heavily relies on TSDoc (which is not stable), so if you don’t wanna follow that specification you need to turn off various things. (I’d consider to use TSDoc if you’re planing on leverage api-extractor)

Conclusion

We as developers have a responsibility to ship the best possible code to our consumers ideally as fast as possible and without unnecessary noise. While this approach is getting good traction in terms of proper bundling your source code into one tree-shaked and minified file, TypeScript type declaration files should not be an exception!

We showcased how this can be done by leveraging existing OSS libraries with brief feature comparison.

Hopefully you’ll be able to apply this pattern sooner than later and ~make the world~ (I meant world of type definitions) a better and faster place.

As always, don’t hesitate to ping me if you have any questions here or on Twitter (my handle @martin_hotell)

Until next time! Cheers! 🖖 🌊 🏄

Principal Engineer | Google Dev Expert/Microsoft MVP | @ngPartyCz founder | Speaker | Trainer. I 🛹, 🏄‍♂️, 🏂, wake, ⚾️& #oss #js #ts

Get the Medium app

A button that says 'Download on the App Store', and if clicked it will lead you to the iOS App store
A button that says 'Get it on, Google Play', and if clicked it will lead you to the Google Play store