Skip to Content
Course content

249: Practice Exercise: Refactoring an any-Heavy Codebase to Strict Types

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

I recently inherited a project where the previous developer seemed to treat any as a shortcut for "I don't feel like thinking about this right now." It's a common trap. You're moving fast, the types are getting complex, and suddenly your codebase is just JavaScript with .ts extensions. It's dangerous because you lose the very reason we're using TypeScript in the first place: the safety net.

Let's look at this snippet I found in the project. It's a basic user settings handler. I'll share the code, and then we'll try to fix it together.

function updateUserSettings(user: any, settings: any) {
  const updatedUser = { ...user, ...settings };
  
  if (updatedUser.role === 'admin') {
    console.log("Updating admin permissions...");
  }

  return updatedUser;
}

const currentUser = { id: 1, name: 'Alice', role: 'user' };
const newSettings = { theme: 'dark', role: 'admin' };

const result = updateUserSettings(currentUser, newSettings);
console.log(result.userName); // Wait, is it 'name' or 'userName'?

Tugging on the first thread

At first glance, this works. But look at that last line: result.userName. The code doesn't crash immediately, but it returns undefined because the property is actually name. If this were a larger app, that bug could propagate through five different components before you noticed it. That's the "any" tax.

My first instinct is to just slap an interface on user. Let's try that.

interface User {
  id: number;
  name: string;
  role: 'admin' | 'user';
}

function updateUserSettings(user: User, settings: any) {
  const updatedUser = { ...user, ...settings };
  // ...
  return updatedUser;
}

I thought I fixed it, but the return type of updateUserSettings is now inferred as User & { [x: string]: any }. Because settings is still any, TypeScript allows me to merge anything into the user object. I can still call result.userName or result.favoritePizza and the compiler will just shrug its shoulders. We've moved the any, but we haven't killed it.

Dealing with the ripple effect

Now I realize the problem is the settings parameter. But wait—settings aren't always a full User object. Sometimes we only update the theme, or just the role. I can't just make settings a User because then the caller would be forced to provide an id and name every time they want to change a theme.

This is where I usually stop and think about Partial. If settings is a partial version of User, we get the benefit of type checking without the rigidity of requiring every field.

function updateUserSettings(user: User, settings: Partial<User>) {
  const updatedUser = { ...user, ...settings };
  
  if (updatedUser.role === 'admin') {
    console.log("Updating admin permissions...");
  }

  return updatedUser;
}

This is better. If I try to pass { role: 'super-admin' }, TypeScript will scream at me because 'super-admin' isn't part of the 'admin' | 'user' union. We've regained control over the role field. But there's still a gap: the theme property. My original newSettings had a theme, but my User interface doesn't. The compiler is now flagging theme as an error.

Hardening the boundaries

I've discovered that my User interface was too narrow. It didn't actually represent the data we were working with. In a real refactor, this is the "discovery phase." The any was hiding the fact that our data model was incomplete.

Let's define a proper UserSettings type and combine them. I'll use an intersection type here because a user has settings.

interface User {
  id: number;
  name: string;
  role: 'admin' | 'user';
}

interface UserSettings {
  theme: 'light' | 'dark';
  notificationsEnabled: boolean;
}

type CompleteUser = User & UserSettings;

function updateUserSettings(user: CompleteUser, settings: Partial<CompleteUser>): CompleteUser {
  return { ...user, ...settings };
}

const currentUser: CompleteUser = { 
  id: 1, 
  name: 'Alice', 
  role: 'user', 
  theme: 'light', 
  notificationsEnabled: true 
};

const newSettings: Partial<CompleteUser> = { theme: 'dark', role: 'admin' };

const result = updateUserSettings(currentUser, newSettings);
// result.userName -> Error: Property 'userName' does not exist on type 'CompleteUser'.
// result.name -> Perfect.

Now the code is honest. The types tell the truth about what the data is and what it can be. The process wasn't just "replacing any with a name"; it was about observing where the compiler failed and using those failures to map out the actual shape of the data. That's how you refactor a codebase without breaking it.




📋 Practical Task

Exercise: Refactoring the Legacy Order Pipeline

You've been handed a legacy order processing module. It's riddled with any types, and bugs are slipping through because the system doesn't know if an order is "Pending," "Shipped," or "Cancelled."

Your Task: Refactor the following code to remove all any types. You must:

  • Create a OrderStatus union type (Pending, Shipped, Delivered, Cancelled).
  • Create an Order interface that includes an id (number), customerName (string), and status (OrderStatus).
  • Ensure the updateOrderStatus function uses Partial<Order> for its updates to allow changing only the status without providing the whole order object.
  • Fix the bug where the code tries to access order.deliveryDate on an order that might not be "Delivered". (Hint: Use a type guard or a conditional check to ensure the property exists).
// LEGACY CODE TO REFACTOR
function updateOrderStatus(order: any, updates: any) {
    return { ...order, ...updates };
}

function printShippingLabel(order: any) {
    console.log(`Shipping to ${order.custName} via ${order.carrier}`);
}

const myOrder = { id: 101, customerName: 'Bob', status: 'Pending' };
const update = { status: 'Shipped', carrier: 'FedEx' };

const updatedOrder = updateOrderStatus(myOrder, update);
printShippingLabel(updatedOrder);
Rating
0 0

There are no comments for now.

to be the first to leave a comment.