Skip to Content
Course content

129: Structuring Types for Long-Term Maintainability

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

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 BaseOrder interface containing properties common to all orders.
  • Create a DraftOrder type that doesn't require shipping or tracking info.
  • Create a ShippedOrder type that guarantees the presence of shippingAddress and trackingNumber.
  • Update the calculateTax and shipOrder functions to use these specific types instead of the generic Order interface.
Rating
0 0

There are no comments for now.

to be the first to leave a comment.