37.2 Generating and Bundling Declaration Files
Right, so you’ve built your library. It’s a masterpiece of type-safe engineering. But if you just ship the raw .ts files or, heaven forbid, the compiled .js without a map, you’re leaving your users in the dark. They get a any-shaped box and have to guess what’s inside. That’s not very friendly. The solution is the Declaration File (.d.ts), and getting it right is what separates the pros from the amateurs.
The Compiler’s Job: declaration and declarationMap
Your first, and most important, job is to tell the TypeScript compiler to generate these files. In your tsconfig.json, these two options are non-negotiable for a library:
{
"compilerOptions": {
"declaration": true,
"declarationDir": "./dist/types",
"declarationMap": true,
"outDir": "./dist/js"
// ... other crucial settings like "target" and "module"
}
}
Here’s what’s happening:
"declaration": true: This is the “do your job” switch. It tellstsc, “Hey, while you’re compiling my beautiful TypeScript to JavaScript, also spit out a.d.tsfile for every.tsfile.” This file contains only the type signatures—no implementation code. It’s the API menu for your library."declarationDir": "./dist/types": This prevents the.d.tsfiles from being vomited into the same directory as your source files. You want a cleandistfolder. The compiled JS goes to./dist/js, and the types go to./dist/types. Neat and separate."declarationMap": true: This is a quality-of-life feature for your users. It generates.d.ts.mapfiles that link the definitions in yourdist/typesfolder back to the original source.tsfiles. This means when a user using your library hits “Go to Definition” in their IDE, they won’t be taken to the often-unreadable.d.tsfile; they’ll be taken to your original, well-commented source code. It’s a tiny gesture that makes you look like a genius.
The Bundler’s Job: Rollup, esbuild, and The Declaration Dilemma
Here’s where things get… interesting. You’re not just running tsc and calling it a day, are you? Of course not. You’re using a bundler like Rollup or esbuild to create tidy, optimized esm and cjs bundles.
These bundlers are brilliant at chewing up JavaScript. Their support for TypeScript and declaration files, however, has historically been a bit of an afterthought. They’ll happily bundle your .ts files into .js, but they often just ignore the .d.ts files entirely. If you just point them at your source, you’ll get a beautiful bundled JS file with no accompanying type definitions. A tragedy!
The solution is to use a dedicated plugin for your bundler that understands TypeScript’s emission process.
For Rollup, the @rollup/plugin-typescript plugin is notoriously bad at handling declaration generation. The professional move is to use rollup-plugin-ts (from the tslib family) or rollup-plugin-dts.
Let’s look at a realistic rollup.config.js snippet:
import { defineConfig } from 'rollup';
import typescript from '@rollup/plugin-typescript';
import { dts } from 'rollup-plugin-dts'; // You'll need to npm install this
export default defineConfig([
// First bundle: the JavaScript itself
{
input: 'src/index.ts',
output: [
{ file: 'dist/esm/index.js', format: 'esm' },
{ file: 'dist/cjs/index.cjs', format: 'cjs' },
],
plugins: [typescript()], // This handles the TS->JS conversion
},
// Second bundle: the type declarations
{
input: 'src/index.ts',
output: { file: 'dist/index.d.ts', format: 'esm' },
plugins: [dts()], // This handles the .d.ts bundling
},
]);
The key here is that you’re creating two separate bundles. One for the code, one for the types. The dts() plugin takes all the declaration files generated by the TypeScript compiler and rolls them into a single, coherent index.d.ts file. This is vital because your src/index.ts probably re-exports things from deep within your src/lib/ folder. You need the final dist/index.d.ts to also reflect that single public API entry point.
For esbuild, the built-in TypeScript loader does support declaration generation, but it’s a bit clunky as it doesn’t naturally bundle them. You often have to run a separate esbuild process just for the types.
The Final Boss: package.json and The types Field
You’ve generated a beautiful, bundled dist/index.d.ts file. Now you have to actually tell the world it exists. This is the part everyone forgets, and it’s infuriating for users.
Your package.json must point to your declaration file. Not doing this is like building a spectacular restaurant and forgetting to put a sign on the door.
{
"name": "my-brilliant-library",
"version": "1.0.0",
"main": "./dist/cjs/index.cjs",
"module": "./dist/esm/index.js",
"types": "./dist/index.d.ts", // <--- This is the line that matters
"exports": {
".": {
"import": {
"types": "./dist/types/index.d.mts", // For future-proofing
"default": "./dist/esm/index.js"
},
"require": {
"types": "./dist/types/index.d.cts",
"default": "./dist/cjs/index.cjs"
}
}
}
}
The "types" field at the root is the classic, universally supported way to point to your declaration file. The new "exports" map is the modern, more explicit way, allowing you to specify different type files for different module systems (note the .d.mts and .d.cts extensions for Node’s new module resolution). For now, including the root "types" field as a fallback is the safest bet for maximum compatibility. If a user’s toolchain can’t find your types, 99% of the time it’s because you messed this up. Don’t mess it up. Your brilliant friend would never forgive you.