Skip to Content
Course content

120: Versioning Public Type Definitions

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

When you're writing a private app, changing a type definition is easy: you rename a property, run a global find-and-replace, and you're done. But when you're publishing a library used by other teams or the public, you don't have that luxury. You can't reach into your users' codebases and fix their breaking changes for them.

I want to show you how to handle this. We're going to build a small "Notification System" library. We'll start with a simple version and then evolve it, navigating the mess that happens when we need to change our public API without making our users hate us.

Defining our first Notification interface

Let's start with a basic interface that we've already "published" to our users. It's simple, and it works.

export interface Notification {
  id: string;
  message: string;
  timestamp: Date;
}

export function sendNotification(n: Notification) {
  console.log(`Sending: ${n.message}`);
}

For a while, this is great. Every consumer of our library is using the message property to define what the user sees.

The "Quick Fix" that breaks everything

Now, imagine a new requirement: we need to support rich text. Instead of a simple string, the message needs to be an object containing both a plainText version and an html version. My first instinct—and I've done this the hard way in the past—is to just update the type to reflect the new reality.

// I'm just going to update this since it's "better" now
export interface Notification {
  id: string;
  content: {
    plainText: string;
    html: string;
  };
  timestamp: Date;
}

Wait. I just committed a cardinal sin of library maintenance. I renamed message to content and changed its type from a string to an object. The moment I publish this version, every single line of code in every project using this library that references notification.message will throw a TypeScript error. I've just created a breaking change in a minor update.

Bridging the gap with Deprecation

To fix this, we need a transition period. We can't force everyone to migrate instantly, so we'll provide both the old and the new way, while signaling that the old way is on its way out. This is where the @deprecated JSDoc tag becomes your best friend—it actually shows up in the user's IDE as a strikethrough.

export interface Notification {
  id: string;
  timestamp: Date;
  
  /** 
   * @deprecated Use 'content' instead. 
   * This will be removed in v2.0.0 
   */
  message?: string; 

  content: {
    plainText: string;
    html: string;
  };
}

export function sendNotification(n: Notification) {
  // We handle the legacy property internally so the app doesn't crash
  const text = n.content ? n.content.plainText : n.message;
  console.log(`Sending: ${text}`);
}

Notice two things here. First, I made message optional. If I kept it required, new users would be forced to provide both the old and new fields, which is annoying. Second, the JSDoc tag warns the developer without breaking their build. You're essentially saying, "This still works, but please stop using it."

Planning the final cleanup

You can't keep deprecated fields forever, or your types become a graveyard of legacy baggage. The strategy here is to tie the removal to your versioning scheme (like SemVer).

  • Minor Version (1.1.0): Introduce content, mark message as @deprecated.
  • Patch Versions (1.1.1, 1.2.0): Maintain both; encourage migration in the docs.
  • Major Version (2.0.0): Completely delete the message property.

By the time you hit 2.0.0, your users have had months to see those strikethroughs in their IDEs and update their code. You've turned a catastrophic breaking change into a managed migration.




📋 Practical Task

Migrating the PaymentMethod Type

You are maintaining a payment library. You have a public type PaymentMethod that currently uses a cardNumber string. You need to change this to a cardDetails object containing number and expiry, but you must avoid breaking existing implementations.

Your Task: Modify the following code to implement a versioning strategy. You must:

  1. Keep the existing cardNumber property so old code still compiles.
  2. Mark cardNumber as deprecated using the correct JSDoc tag.
  3. Make cardNumber optional.
  4. Add the new cardDetails object property.
  5. Update the processPayment function to prioritize cardDetails but fall back to cardNumber if the new property is missing.
export interface PaymentMethod {
  id: string;
  cardNumber: string;
}

export function processPayment(method: PaymentMethod) {
  console.log(`Processing payment for card: ${method.cardNumber}`);
}
Rating
0 0

There are no comments for now.

to be the first to leave a comment.