34.7 Rollup for Library Bundling with Type Declarations
Right, so you’ve written a fantastic library in TypeScript. It’s clean, it’s typed, it’s a work of art. Now you need to ship it so that other humans—using different module systems, build tools, and possibly even different versions of Node.js—can actually use it without setting their own hair on fire. This is where Rollup comes in. Think of it less as a “bundler” and more as a “packaging engineer.” Its job is to take your beautifully structured source code and wrap it up in a neat, universally compatible box, complete with a bow (your type declarations).
We’re not building a single-page application here; we’re building a library. Our needs are different. We need multiple output formats (ES modules for the cool kids, CommonJS for everyone else), tiny bundles, and, crucially, those .d.ts files so our consumers get full IntelliSense. Forget Create React App’s webpack setup; that’s a sledgehammer for a nut we’re not trying to crack.
The Core Rollup Configuration
Let’s start with the absolute minimum viable rollup.config.mjs. We use .mjs because the Rollup ecosystem is modern and we like to use import statements in our config file. This is non-negotiable if you want to avoid a future headache with require/import interop nonsense.
// rollup.config.mjs
import { defineConfig } from 'rollup';
import typescript from '@rollup/plugin-typescript';
import { dts } from 'rollup-plugin-dts';
// This is the main library code bundling
const config = defineConfig([
{
// Your library's entry point. This is what you export from `src/index.ts`.
input: 'src/index.ts',
output: [
{
// ES Module version for modern bundlers
file: 'dist/index.esm.js',
format: 'esm',
},
{
// CommonJS version for Node.js and older bundlers
file: 'dist/index.cjs.js',
format: 'cjs',
},
],
plugins: [
// The crucial plugin: compiles TS and emits declarations (see note below)
typescript({
declaration: true,
declarationDir: './dist',
// We let Rollup handle the module bundling, so tell TS to NOT output anything.
outDir: './dist',
rootDir: './src',
}),
],
},
{
// This is a separate bundle run *just* for the type declarations.
input: 'dist/index.d.ts', // This file is generated by the plugin above
output: [{ file: 'dist/index.d.ts', format: 'esm' }],
plugins: [dts()],
},
]);
export default config;
Wait, what? Two separate configs? Yes. Here’s the deal: the first block uses @rollup/plugin-typescript to handle the TypeScript compilation and generate your .d.ts files. But there’s a catch: it just spits them out into the dist folder verbatim. If you’re using import statements in your source, your emitted index.d.ts file will also contain import statements. This breaks consumers using allowSyntheticDefaultImports or older tooling. The second block uses the brilliant rollup-plugin-dts to take that raw declaration file, bundle it properly, and ensure all those imports are resolved into a single, clean, consumer-friendly type definition file. It’s a two-step process for a one-step outcome: perfection.
The Non-Negotiable Plugins
The above config is the heart, but you need a few more organs to make the body work. You’ll need to install these as devDependencies.
// Add these to your rollup.config.mjs imports
import resolve from '@rollup/plugin-node-resolve';
import commonjs from '@rollup/plugin-commonjs';
import terser from '@rollup/plugin-terser';
import cleanup from 'rollup-plugin-cleanup';
// Then, add them to the plugins array of your main config (the first one):
plugins: [
resolve(), // Tells Rollup how to find third-party modules in node_modules
commonjs(), // Converts CommonJS modules (which some deps might be) to ES6
typescript({...}),
terser(), // Minifies the output. Crucial for a library.
cleanup({ comments: 'none' }), // Strips comments for an even smaller bundle
],
The resolve and commonjs plugins are your polite ambassadors to the npm ecosystem. They allow your library to import from dependencies that might be written in CommonJS without everything blowing up. terser is non-negotiable; nobody wants a bloated library. And cleanup is my personal favorite for stripping out all those unnecessary comments that add weight to your bundle for precisely zero runtime benefit.
The package.json Entrypoints
This is where most people faceplant spectacularly. Your package.json is the public API for your package manager, and you must tell it exactly where to find the different bundles you just made.
{
"name": "my-brilliant-library",
"version": "1.0.0",
"main": "./dist/index.cjs.js", // The fallback for Node.js and old-school require()
"module": "./dist/index.esm.js", // The modern path for bundlers like Webpack and Rollup
"types": "./dist/index.d.ts", // The single, bundled file of truth for types
"exports": {
".": {
"require": "./dist/index.cjs.js", // For `require()`
"import": "./dist/index.esm.js", // For `import`
"types": "./dist/index.d.ts" // And types for both
}
},
"files": ["dist"], // This is crucial! It tells npm what to publish.
"scripts": {
"build": "rollup -c", // Run our config
"prepublishOnly": "npm run build" // Build automatically before publishing
}
}
The exports field is the modern, more robust alternative to main and module. It explicitly maps the required and imported paths. This is what prevents the dreaded “dual package hazard” where your library might accidentally be loaded twice in different formats in the same application. Use it. Your consumers will thank you, even if they don’t know why.
The final, most important step? Test it. npm pack and then install the resulting tarball into a test project. Try to require it. Try to import it. See if the types show up in VS Code. This last-mile testing is what separates a professional-grade library from a “it worked on my machine” mess. Now go ship it.