Skip to Content
Course content

111: Practice Exercise: Building a Type-Safe Redux-Like Store

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

How do I prevent the state from becoming "any" inside the store?

The biggest mistake I see when people first try to build a store is declaring the state as a generic object or, even worse, any. If you do that, you lose all the benefits of TypeScript the moment you try to access a property in your UI. You want to use a Generic type for the store itself so it can adapt to whatever state shape you're using for a specific project.

type Store = {
  getState: () => S;
  dispatch: (action: any) => void; // We'll fix this 'any' in a second
};

// Now, when I create a store for my Shopping Cart, 
// the state is locked to CartState.
interface CartState {
  items: { id: string; name: string; price: number }[];
  total: number;
}

function createStore<S>(reducer: (state: S, action: any) => S, initialState: S): Store<S> {
  let state = initialState;
  return {
    getState: () => state,
    dispatch: (action) => {
      state = reducer(state, action);
    }
  };
}

How do I make sure dispatch only accepts valid actions?

In the code above, dispatch takes any. That's a disaster waiting to happen. You could dispatch { type: 'EXPLODE_COMPUTER' } and TypeScript wouldn't blink. To fix this, we use a Discriminated Union. This is the "secret sauce" of Redux-like patterns in TypeScript. By defining a specific set of action types, we force the developer to only send actions the reducer actually knows how to handle.

type CartAction = 
  | { type: 'ADD_ITEM'; payload: { id: string; name: string; price: number } }
  | { type: 'REMOVE_ITEM'; payload: { id: string } }
  | { type: 'CLEAR_CART' };

// Now we update our Store type to use a generic Action type too
type Store<S, A> = {
  getState: () => S;
  dispatch: (action: A) => void;
};

function createStore<S, A>(reducer: (state: S, action: A) => S, initialState: S): Store<S, A> {
  let state = initialState;
  return {
    getState: () => state,
    dispatch: (action) => {
      state = reducer(state, action);
    }
  };
}

What's the cleanest way to write the reducer without losing type safety?

This is where the magic of the Discriminated Union comes in. When you use a switch statement on the type property, TypeScript automatically "narrows" the type of the action. Inside the 'ADD_ITEM' block, TypeScript knows for a fact that action.payload contains the item details. If you try to access action.payload inside the 'CLEAR_CART' block, the compiler will yell at you because that action doesn't have a payload. I personally find this much safer than using if statements.

const cartReducer = (state: CartState, action: CartAction): CartState => {
  switch (action.type) {
    case 'ADD_ITEM':
      // TypeScript knows action.payload has id, name, and price here
      return {
        ...state,
        items: [...state.items, action.payload],
        total: state.total + action.payload.price
      };
    case 'REMOVE_ITEM':
      // TypeScript knows action.payload only has an id here
      const filteredItems = state.items.filter(item => item.id !== action.payload.id);
      return {
        ...state,
        items: filteredItems,
        total: filteredItems.reduce((acc, item) => acc + item.price, 0)
      };
    case 'CLEAR_CART':
      return { items: [], total: 0 };
    default:
      return state;
  }
};

const cartStore = createStore(cartReducer, { items: [], total: 0 });



📋 Practical Task

Exercise: Implementing a Cart Discount Feature

You have a functioning Shopping Cart store, but the business requirements just changed. You need to implement a coupon system that allows users to apply a percentage discount to their total.

  • Modify the CartState: Add a discountPercent property (a number).
  • Expand the CartAction union: Add a new action type 'APPLY_COUPON' that takes a payload containing the code (string) and the percent (number).
  • Update the cartReducer: Handle the 'APPLY_COUPON' action to update the discountPercent in the state.
  • Refactor the Total calculation: Ensure that the total returned in the state (or calculated via a helper) accounts for the discountPercent.

Your goal is to ensure that if you try to dispatch APPLY_COUPON without a percent value, the TypeScript compiler catches the error before you ever run the code.

Rating
0 0

There are no comments for now.

to be the first to leave a comment.