-
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
129: Structuring Types for Long-Term Maintainability
I've seen this exact scenario play out in dozens of production codebases. You start with a simple User interface. It's clean, it's easy, and it works. But as the app grows, that one interface becomes a "God Type"—a massive object that represents the user in the database, the user in the API response, and the user in the UI state. Everything depends on it.
// The "God Type" that seems fine at first
interface User {
id: string;
username: string;
email: string;
passwordHash: string; // Only used in auth
lastLogin: Date; // Only used in admin dashboards
themePreference: 'light' | 'dark';
stripeCustomerId: string; // Only used in billing
}
function renderUserProfile(user: User) {
return `${user.username}`;
}
function updateBilling(user: User, newPlan: string) {
console.log(`Updating ${user.stripeCustomerId} to ${newPlan}`);
}
At first glance, this is perfectly valid TypeScript. But here is where the maintainability nightmare begins. Imagine you're tasked with optimizing the renderUserProfile function. You realize the API call providing the user data is slow, so you change the backend to return a "slim" version of the user—excluding the stripeCustomerId and passwordHash to save bandwidth.
Suddenly, your entire frontend is screaming. Even though renderUserProfile doesn't even touch the billing ID, it's typed to require a User, and a User must have a stripeCustomerId. You've created a hard dependency between your UI components and your billing infrastructure. I call this "type fragility."
The Fragility of Over-Specified Interfaces
The problem here is that we're using a single source of truth for three different contexts. The database cares about passwordHash; the billing service cares about stripeCustomerId; the UI cares about the username. When you bundle them all into one interface, any change to any single context forces a refactor across the entire application.
If you find yourself adding ? (optional markers) to half the fields in your interface just to make a function stop complaining, you're not solving the problem—you're just hiding it. You're telling TypeScript "I don't actually know what's in here," which defeats the purpose of using a type system in the first place.
Decoupling through Domain-Specific Shapes
The fix is to stop thinking about "The User" and start thinking about "The User in this specific context." We do this by defining small, atomic building blocks and composing them. I prefer to define a "Base" type for shared identity and then extend or pick from it for specific use cases.
// 1. The atomic core: What defines a user everywhere?
interface UserBase {
id: string;
username: string;
}
// 2. Context-specific types
interface UserProfile extends UserBase {
email: string;
themePreference: 'light' | 'dark';
}
interface UserBilling extends UserBase {
stripeCustomerId: string;
}
interface UserAuth extends UserBase {
passwordHash: string;
}
// Now, these functions are decoupled
function renderUserProfile(user: UserProfile) {
return `${user.username}`;
}
function updateBilling(user: UserBilling, newPlan: string) {
console.log(`Updating ${user.stripeCustomerId} to ${newPlan}`);
}
Now, if the billing team decides to switch from Stripe to Paddle and changes stripeCustomerId to paddleId, the renderUserProfile function doesn't care. It doesn't even know the billing type exists. You've isolated the "blast radius" of your changes.
Strategically using Pick and Omit for Transitions
I'll be honest: sometimes you can't rewrite the whole architecture overnight. If you're stuck with a large legacy type but want to start moving toward maintainability, use Pick and Omit. These utility types allow you to create these smaller, focused shapes without duplicating the property definitions.
interface LegacyUser {
id: string;
username: string;
email: string;
passwordHash: string;
lastLogin: Date;
themePreference: 'light' | 'dark';
stripeCustomerId: string;
}
// Create a maintainable slice of the legacy type
type UserUIState = Pick<LegacyUser, 'id' | 'username' | 'themePreference'>;
function updateTheme(user: UserUIState, theme: 'light' | 'dark') {
// This function is now safe from changes to passwordHash or stripeCustomerId
}
My rule of thumb is this: if a function only needs two properties of an object, it should probably be typed to only accept those two properties. It makes your code easier to test, easier to document, and significantly less likely to break when someone changes a database column six months from now.
📋 Practical Task
Refactoring the E-commerce Order Lifecycle Types
You have been handed a legacy Order interface that is causing massive rebuilds and type errors across the app. Currently, it handles everything from a "Draft" order (where the user is still adding items) to a "Shipped" order (which has a tracking number).
interface Order {
orderId: string;
customerId: string;
items: string[];
totalAmount: number;
shippingAddress?: string; // Optional because draft orders don't have it
trackingNumber?: string; // Optional because draft orders don't have it
paymentStatus: 'pending' | 'paid' | 'failed';
deliveryDate?: Date; // Optional because it's only for shipped orders
}
function calculateTax(order: Order) {
return order.totalAmount * 0.1;
}
function shipOrder(order: Order) {
if (!order.shippingAddress) {
throw new Error("Cannot ship without address!");
}
console.log(`Shipping to ${order.shippingAddress}`);
}
Your Goal: Refactor this into a maintainable structure.
- Create a
BaseOrderinterface containing properties common to all orders. - Create a
DraftOrdertype that doesn't require shipping or tracking info. - Create a
ShippedOrdertype that guarantees the presence ofshippingAddressandtrackingNumber. - Update the
calculateTaxandshipOrderfunctions to use these specific types instead of the genericOrderinterface.
There are no comments for now.