37.6 Versioning Type Definitions Alongside Library Versions
Now, let’s talk about something that seems like it should be simple but is, in fact, a delightful little minefield: keeping your type definitions in sync with your actual library code. You’ve just shipped a fantastic new feature in my-awesome-lib@2.1.0. You’re feeling great. Then you get the issue: “Types are broken!!1!” You forgot to update the index.d.ts file. We’ve all been there. It’s the equivalent of putting on a sharp suit and then forgetting to wear pants.
The goal is simple: your type definitions should be a perfect, immutable reflection of your library’s API for that exact version. Not the version you’re working on in a feature branch, not the version you wish you had shipped. The one that’s actually on npm. The simplest, most bulletproof way to achieve this is to version them together.
The Golden Rule: One Version, One Truth
Your type definitions are not a separate entity; they are an integral part of your library’s public contract. Therefore, they must be versioned, released, and tagged in lockstep with the library itself. The version number in your package.json is the single source of truth for both your runtime code and your type definitions.
The moment you even consider publishing type definitions for my-awesome-lib@2.1.0 under the @types/my-awesome-lib namespace at a different version (e.g., @types/my-awesome-lib@2.2.0), you have created a nightmare for your users and yourself. Don’t do that. The @types namespace on npm is a fantastic system for definitely-typed community-maintained types for libraries that don’t bundle their own. For your own library, it’s just indirection and chaos.
Bundling Types: The Modern, Sane Approach
The modern toolchain makes this incredibly easy. You write your types, either manually in a .d.ts file or, better yet, you generate them directly from your source code. Then you point your package.json to them.
// package.json
{
"name": "my-awesome-lib",
"version": "2.1.0",
"types": "./dist/index.d.ts",
"files": [
"dist/"
]
}
That’s it. The "types" field is the entry point, and the "files" array ensures your dist directory (containing both your compiled JS and your type definitions) gets included in the npm package. When a user installs my-awesome-lib@2.1.0, they get the correct types for my-awesome-lib@2.1.0. It’s atomic. It’s perfect.
Generating Types from Source (The Best Thing Since Sliced Bread)
If you’re writing a library in TypeScript (which you should be), you’re already 90% of the way there. Your source code is your type definition. You just need to get the compiler to emit the .d.ts files for you.
Here’s a bare-bones tsconfig.json for library compilation. The key settings are "declaration": true and "declarationMap": true.
// tsconfig.lib.json
{
"extends": "./tsconfig.json",
"compilerOptions": {
"target": "ES2020",
"module": "ESNext",
"outDir": "./dist",
"rootDir": "./src",
"declaration": true,
"declarationMap": true, // Optional but highly recommended for debugging
"strict": true
},
"include": ["src/**/*"],
"exclude": ["**/*.test.ts"]
}
Your build script (package.json) then just becomes:
{
"scripts": {
"build": "tsc -p tsconfig.lib.json",
"prepublishOnly": "npm run build"
}
}
Now, when you run npm run build, tsc will compile your src/index.ts into both dist/index.js (the runtime code) and dist/index.d.ts (the type definitions). They are created from the same source, at the same time, guaranteeing consistency. This is the way.
The Pitfall of “I’ll Just Do It Manually”
I know what you’re thinking. “My library is small, I’ll just hand-write the index.d.ts file. It’s fine.” It is not fine. It is a trap. You will forget. You will make a typo. You will expose a private function because you copied and pasted the wrong thing. The mechanical sympathy of generating types from your actual source code eliminates an entire class of human error. Use it.
Handling Breaking Changes (This Is Where You Earn Your Pay)
A breaking change in your runtime API must be a breaking change in your type API. There is no negotiation. If you remove a function doTheThing() from your library, the type definitions must also reflect its absence. If you don’t, your types are lying. Lying types are worse than no types because they fail at compile time, not at runtime, which is the whole point of using TypeScript!
This is why locking the versions together is non-negotiable. A user on my-awesome-lib@1.5.0 cannot and should not get type definitions for my-awesome-lib@2.0.0. The version number is the coupling that prevents this. When you make a major version bump, you are telling the world that both the runtime and the types have changed in an incompatible way.
So, the TL;DR is this: Generate your types from your source code as part of your build process, bundle them in the package, and let npm’s versioning system do the heavy lifting. It’s the only way to be sure.