Skip to Content
Course content

221: Versioning Type Definitions with SemVer

Click on the "Edit" button in the top corner of the screen to edit your slide content.

I want to show you a scenario that has happened to me more than once. You're maintaining a shared utility library used by several other teams in your company. Everything is going great until one morning, your Slack starts blowing up because three different projects suddenly won't compile.

// In the consumer's code:
import { UserProfile } from '@company/auth-lib';

const updateProfile = (user: UserProfile) => {
  console.log(user.username); 
};

// Error: Property 'username' does not exist on type 'UserProfile'.
// Did you mean 'displayName'?

You check your recent changes. You didn't touch the runtime JavaScript logic at all. You only "cleaned up" the type definitions in the .d.ts files. You changed username: string to displayName: string to better match the database schema. Since you didn't change any actual logic, you pushed it as a minor version bump (v1.1.0 to v1.2.0). You thought, "It's just a type change; it can't possibly break the production build."

The Invisible Breaking Change

Here is the hard truth about TypeScript: a breaking change in your types is a breaking change for your users.

In the example above, the JavaScript code might still work if the underlying object still has a username property at runtime, but the TypeScript compiler is now blocking the build. If your users are using a caret range in their package.json (like ^1.1.0), npm will automatically pull in your v1.2.0 update, and their CI/CD pipeline will instantly fail.

When we talk about Semantic Versioning (SemVer), we usually think about the API surface—the functions and classes. But in a TypeScript project, your type definitions are your API surface. If you change a type in a way that forces the consumer to change their code to make it compile, you have introduced a breaking change.

Defining "Breaking" in Type Space

I've found it helpful to categorize type changes into three buckets to decide which version number to bump:

  • Patch (0.0.x): Fixing a typo in a type comment or correcting a type that was objectively wrong (e.g., changing number to string because the API actually returns a string) without changing how the user calls the function.
  • Minor (0.x.0): Adding a new optional property to an interface, adding a new overloaded function signature, or adding a new exported type. These are additive and don't break existing code.
  • Major (x.0.0): Renaming a property, changing an optional property to required, narrowing a type (e.g., changing string to 'admin' | 'user'), or removing an exported type entirely.

A common mistake I see is "narrowing." If you change a function argument from any to string, you might think you're just making the code safer. But if a user was passing a number into that function, their code just broke. That's a Major bump.

Versioning the .d.ts separately

If you are publishing a JavaScript library with separate TypeScript definitions (the @types/library-name pattern), you have an extra layer of complexity. Ideally, the version of the types should track the version of the library. However, if you find yourself needing to fix a type bug without releasing a new version of the JS logic, you can bump the patch version of the types package specifically.

My advice? Keep it simple. If you're shipping a TS-first library, treat your index.d.ts as the primary contract. If you change a line in that file and it causes a red squiggly line in a consumer's editor, you've just earned yourself a Major version bump.




📋 Practical Task

Audit the Version Bump for the "Payment-Core" Library

You are the lead maintainer for a library called payment-core. The current version is v2.4.1. You have made the following three changes to the type definitions in a single PR. Your task is to determine the correct next version number according to SemVer and justify why.

// CHANGE 1: 
// From: interface Transaction { id: string; amount: number; }
// To:   interface Transaction { id: string; amount: number; currency?: string; }

// CHANGE 2:
// From: function getStatus(id: string): string
// To:   function getStatus(id: string): 'pending' | 'completed' | 'failed'

// CHANGE 3:
// From: interface Config { timeout: number; }
// To:   interface Config { timeout: number; retryLimit: number; } // retryLimit is now REQUIRED

Instructions:

  1. State the new version number.
  2. Explain which specific change(s) forced that version bump.
  3. Identify which change was "additive" and which was "breaking."
Rating
0 0

There are no comments for now.

to be the first to leave a comment.