TypeScript
Completed
-
Section 1: Getting Started
-
Section 2: Basic Types
-
Section 3: Functions and Objects
-
Section 4: Advanced Types
-
Section 5: Object-Oriented TypeScript
-
Section 6: Working with Modules
-
Section 7: Tooling and Practice
-
Section 8: Type-Level Programming
-
Section 9: TypeScript with Backends
-
Section 10: Testing Typed Code
-
Section 11: Data Validation with Types
-
Section 12: Practical Projects
-
Section 13: Compiler Internals
-
Section 14: Configuration Deep Dive
-
Section 15: Enums, Symbols, and Special Types
-
Section 16: Working with Async Code
-
Section 17: TypeScript and the DOM
-
Section 18: Advanced Generics Practice
-
Section 19: Working with Third-Party Types
-
Section 20: Monorepo and Large-Scale Practices
-
Section 21: Common Pitfalls and Best Practices
-
Section 22: Interview Practice
-
Section 23: Handbook: Narrowing In Depth
-
Section 24: Handbook: Object Types In Depth
-
Section 25: Handbook: Classes In Depth
-
Section 26: Handbook: Modules In Depth
-
Section 27: Handbook: Declaration Files In Depth
-
Section 28: JSX and Namespaces
-
Section 29: Compiler Configuration Reference
-
Section 30: More Practice Exercises
-
Section 31: Handbook: Everyday Types Deep Dive
-
Section 32: Utility Types Full Reference
-
Section 33: Decorators Reference
-
Section 34: Mixins and Advanced OOP Patterns
-
Section 35: Iterators and Generators Typing
-
Section 36: More Type-Level Programming Practice
-
Section 37: TypeScript Ecosystem Tools
-
Section 38: TypeScript with Testing Frameworks
-
Section 39: TypeScript for Library Authors
-
Section 40: More Interview Practice
-
Section 41: Handbook: Functions In Depth
-
Section 42: Handbook: Type Manipulation Deep Dive
-
Section 43: More Real-World Patterns
-
Section 44: TypeScript Release Notes Highlights
-
Section 45: Final Practice and Review
120: Versioning Public Type Definitions
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, markmessageas@deprecated. - Patch Versions (1.1.1, 1.2.0): Maintain both; encourage migration in the docs.
- Major Version (2.0.0): Completely delete the
messageproperty.
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:
- Keep the existing
cardNumberproperty so old code still compiles. - Mark
cardNumberas deprecated using the correct JSDoc tag. - Make
cardNumberoptional. - Add the new
cardDetailsobject property. - Update the
processPaymentfunction to prioritizecardDetailsbut fall back tocardNumberif 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}`);
}
There are no comments for now.