Skip to Content
Course content

80: Building a Type-Safe State Management Store

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

A few years ago, I was mentoring a junior dev on a large dashboard project. We had a custom state store that was essentially just a big object wrapped in a class. One afternoon, he renamed user.displayName to user.fullName in the state interface. He ran a global search-and-replace, felt confident, and pushed the code. But he missed one specific reducer—a legacy piece of code tucked away in a notification handler. The app didn't crash; it just silently stopped rendering the user's name in the top-right corner. We spent two hours debugging a "ghost" bug that TypeScript should have caught in milliseconds.

The problem was that our store was too permissive. It accepted any "action" object and returned a state that was cast as any internally. When you're building your own state management logic, the goal isn't just to store data; it's to create a system where it is mathematically impossible to dispatch an action that the store doesn't understand, or to update the state with a value of the wrong type.

Defining the State Blueprint with Generics

To avoid the "silent failure" scenario, we need a store that is generic over its state. If we hardcode the state type, the store is only useful for one feature. If we use any, we're back to square one. Instead, we define a Store class that takes a type parameter T.

class Store<T> {
  private state: T;
  private listeners: ((state: T) => void)[] = [];

  constructor(initialState: T) {
    this.state = initialState;
  }

  getState(): T {
    return this.state;
  }

  // We'll refine this update method in a moment
  update(newState: Partial<T>): void {
    this.state = { ...this.state, ...newState };
    this.listeners.forEach(listener => listener(this.state));
  }

  subscribe(listener: (state: T) => void) {
    this.listeners.push(listener);
    return () => {
      this.listeners = this.listeners.filter(l => l !== listener);
    };
  }
}

By using T, the store now knows exactly what getState() returns. If T is a UserPreferences interface, you get autocomplete and type checking everywhere you touch the state. I personally prefer Partial<T> for simple updates, but for complex state, that's not enough—we need a way to handle specific logic based on the intent of the change.

Enforcing Logic with Discriminated Unions

This is where most developers trip up. They create an Action interface with optional fields, which leads to a mess of if (action.type === '...') blocks where TypeScript still isn't sure what the payload is. The secret is the Discriminated Union. You define a set of specific action types, each with its own required payload.

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

interface CartState {
  items: { id: string; price: number }[];
  total: number;
}

Now, when you write your reducer, TypeScript uses "type narrowing." Once you check if action.type === 'ADD_ITEM', the compiler knows for a fact that action.payload contains an id and a price. It won't even let you try to access payload on a CLEAR_CART action because it knows that action has no payload. It's a safety net that catches bugs before you even hit "Save."

Bridging the Store and the Reducer

To tie this all together, we can extend our store to accept a reducer function. This separates the mechanism of storing data from the logic of how that data changes. I like to think of the store as the vault and the reducer as the only person with the key who knows how to reorganize the contents.

type Reducer<T, A> = (state: T, action: A) => T;

class TypedStore<T, A> {
  private state: T;
  private reducer: Reducer<T, A>;
  private listeners: ((state: T) => void)[] = [];

  constructor(initialState: T, reducer: Reducer<T, A>) {
    this.state = initialState;
    this.reducer = reducer;
  }

  dispatch(action: A): void {
    this.state = this.reducer(this.state, action);
    this.listeners.forEach(l => l(this.state));
  }

  getState(): T {
    return this.state;
  }

  subscribe(listener: (state: T) => void) {
    this.listeners.push(listener);
    return () => {
      this.listeners = this.listeners.filter(l => l !== listener);
    };
  }
}

With this architecture, you've built a miniature version of Redux or Vuex, but it's tailored exactly to your needs and fully type-safe. You can't dispatch a random string or an object with the wrong properties; the compiler simply won't allow it.




📋 Practical Task

Exercise: Building a Type-Safe Shopping Cart Manager

Your task is to implement a type-safe store for a shopping cart. You need to ensure that the state cannot be updated with invalid data and that actions are strictly typed.

  • Define the State: Create a CartState interface with items (an array of objects containing id: string and quantity: number) and couponCode (a string or null).
  • Define the Actions: Create a discriminated union CartAction with three types:
    • 'UPDATE_QUANTITY': must include a payload with id and quantity.
    • 'APPLY_COUPON': must include a payload with the code string.
    • 'RESET_CART': no payload.
  • Implement the Reducer: Write a function cartReducer that takes the current state and a CartAction` and returns the new state. Ensure you use a switch statement to narrow the action types.
  • Instantiate the Store: Create an instance of the TypedStore (using the class logic from the lesson) passing in an initial state and your reducer.
  • Test the Safety: Try to dispatch an action with a missing payload property (e.g., UPDATE_QUANTITY without an id) and verify that TypeScript throws a compile-time error.
Rating
0 0

There are no comments for now.

to be the first to leave a comment.