Skip to Content
Course content

212: Practice Exercise: Building a Type-Safe Reducer

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

We've spent a fair amount of time talking about the theory of unions and type narrowing. But if you've ever worked with Redux or the useReducer hook in React, you know that this is where the rubber actually meets the road. A reducer is basically just a big state machine, and if you don't type it correctly, you'll end up with a nightmare of any casts or runtime crashes when an action doesn't have the payload you expected.

Let's build a small shopping cart manager together. I want to keep it simple: we can add items, remove items, and clear the whole cart.

Defining the Shape of our Shopping Cart

First, I need a clear definition of what my state looks like. I'm not going to overcomplicate this; just an array of items and a total price. I'll start by defining the CartItem and the CartState.

interface CartItem {
  id: string;
  name: string;
  price: number;
}

interface CartState {
  items: CartItem[];
  total: number;
}

const initialState: CartState = {
  items: [],
  total: 0,
};

Wiring up the Reducer Logic

Now, here is where most people start by just creating a generic Action interface with a type: string. I'll do that for a second just to show you why it's a bad idea.

interface Action {
  type: string;
  payload?: any;
}

function cartReducer(state: CartState, action: Action): CartState {
  switch (action.type) {
    case 'ADD_ITEM':
      // I'm assuming the payload is a CartItem here
      return {
        ...state,
        items: [...state.items, action.payload],
        total: state.total + action.payload.price,
      };
    default:
      return state;
  }
}

Wait, why is this property undefined?

Look at that ADD_ITEM case. I'm accessing action.payload.price. Because I typed the payload as any, TypeScript isn't complaining yet. But if I accidentally pass a string as the payload, or forget the payload entirely, the app crashes at runtime. That's exactly what we're trying to avoid. Even worse, if I want to add a REMOVE_ITEM action that takes an id: string instead of a whole CartItem, my Action interface becomes a confusing mess of optional properties.

I've made this mistake in plenty of projects—relying on any because "it's just a simple action." It's a trap. Let's fix this using a discriminated union.

Tightening the Types with Discriminated Unions

The trick is to define each action as its own type and then union them together. This way, when we check the type property in our switch statement, TypeScript "narrows" the action to the specific type that matches that string. It knows exactly what the payload is for every single case.

type CartAction = 
  | { type: 'ADD_ITEM'; payload: CartItem }
  | { type: 'REMOVE_ITEM'; payload: string } // only need the ID here
  | { type: 'CLEAR_CART' }; // no payload needed

function cartReducer(state: CartState, action: CartAction): CartState {
  switch (action.type) {
    case 'ADD_ITEM':
      // TypeScript now knows action.payload is a CartItem
      return {
        ...state,
        items: [...state.items, action.payload],
        total: state.total + action.payload.price,
      };
    case 'REMOVE_ITEM':
      // TypeScript now knows action.payload is a string (the ID)
      const filteredItems = state.items.filter(item => item.id !== action.payload);
      return {
        ...state,
        items: filteredItems,
        total: filteredItems.reduce((sum, item) => sum + item.price, 0),
      };
    case 'CLEAR_CART':
      return initialState;
    default:
      // This part is crucial for exhaustive checking
      return state;
  }
}

Now, if I try to access action.payload.price inside the REMOVE_ITEM block, TypeScript will throw an error because it knows the payload is just a string. This is where the power of TypeScript really shines in state management—it forces you to handle the data correctly for every specific action type.




📋 Practical Task

Exercise: Expanding the Cart Reducer with Quantity Logic

In the previous example, we only handled adding and removing items. Your task is to update the CartAction union and the cartReducer to handle updating the quantity of an item already in the cart.

  • Modify the CartItem interface to include a quantity: number property.
  • Add a new action type to the CartAction union called UPDATE_QUANTITY. It should carry a payload containing both the id: string of the item and the newQuantity: number.
  • Implement the UPDATE_QUANTITY case in the reducer. You'll need to map through the items, find the one that matches the ID, and update its quantity while also recalculating the total price of the cart.
  • Ensure type safety: Make sure you aren't using any anywhere in your new logic.
Rating
0 0

There are no comments for now.

to be the first to leave a comment.