Building Type-Safe State Machines with Discriminated Unions: A Comprehensive Guide

Introduction to State Machines and Discriminated Unions

In modern software development, managing complex application state remains one of the most challenging aspects of building reliable systems. As applications grow in complexity, traditional approaches to state management often lead to unpredictable behaviors, subtle bugs, and maintenance nightmares. Type-Safe State Machines offer a proven mathematical model for structuring state transitions in a predictable manner, while discriminated unions provide the type safety necessary to implement these systems with compile-time verification. When combined, these concepts enable developers to build robust, maintainable, and error-resistant applications.

The significance of this approach becomes evident when considering the alternative. Without systematic state management, developers often resort to tracking state through multiple boolean flags or string literals—an approach notoriously prone to invalid state combinations and difficult to refactor. As we’ll explore throughout this article, discriminated unions paired with state machine patterns offer a superior alternative that captures state transitions in the type system itself, moving what would typically be runtime errors to compile-time feedback.

This comprehensive guide will explore the theoretical foundations and practical implementation of type-safe state machines using discriminated unions. We’ll examine real-world examples, advanced patterns, and best practices that you can apply immediately in your projects. By the end of this article, you’ll understand how to leverage these techniques to create more reliable and maintainable software systems.

Understanding Discriminated Unions

What Are Discriminated Unions?

Discriminated unions (also known as tagged unions) are a type system feature that allows a variable to hold values of different types that are related but distinct. Each possible type in the union contains a common property—called a “discriminant” or “tag”—that has a different literal type for each variant. This discriminant enables the type checker to determine which specific type is being used at any given point in the code .

In essence, a discriminated union represents a value that can be one of several differently shaped types, with the tag serving as a runtime indicator of the current shape. This concept, while particularly elegant in functional languages like F# , has proven equally valuable in mainstream languages like TypeScript for building reliable applications.

Basic Structure and Syntax

Let’s examine a typical discriminated union representing different geometric shapes:

type Shape = 
  | { kind: "circle"; radius: number }
  | { kind: "square"; sideLength: number }
  | { kind: "rectangle"; width: number; height: number };

In this example, kind serves as the discriminant property. Each variant shares this property but with different literal types ("circle""square", or "rectangle"). Each variant also contains type-specific properties that are only relevant to that particular shape .

Benefits Over Traditional Approaches

Discriminated unions offer several significant advantages over alternative approaches like optional properties or inheritance hierarchies:

  • Type Safety: TypeScript can verify that you’ve handled all possible cases in the union, eliminating whole categories of missing case errors .
  • Explicit Intent: The code clearly communicates the different possibilities and their specific data requirements .
  • Refactoring Resilience: Adding new variants forces you to update all the code that handles the union, preventing incomplete implementations .
  • Self-Documenting Code: The discriminant property makes the code more readable and understandable .

Consider the alternative using optional properties:

// Without discriminated union - more error-prone
type Shape = {
  kind: string,
  radius?: number,
  sideLength?: number,
  width?: number,
  height?: number
};

This approach requires extensive runtime checks and offers no compiler guarantees that all cases are handled correctly .

Real-World Application: API Response Handling

A common use case for discriminated unions is handling different API response types. Traditional approaches rely on checking for the presence or absence of properties, which is fragile and error-prone:

// Without discriminated unions
if ("message" in fetchedData) {
  return { error: fetchedData.message };
}
if ("error" in fetchedData) {
  return { error: fetchedData.error };
}

With discriminated unions, the approach becomes much more robust:

type ErrorResponse = {
  message: string;
  statusCode: number;
  cause: string;
  response_type: "error";
};

type SuccessResponse = {
  statusCode: number;
  data: Record<string, unknown>;
  response_type: "success";
};

type Result = ErrorResponse | SuccessResponse;

// Using the discriminator for reliable type narrowing
if (fetchedData.response_type === "error") {
  // TypeScript knows fetchedData is ErrorResponse
  return { error: fetchedData.message };
}
// TypeScript knows fetchedData is SuccessResponse here
const response = fetchedData.data;

This pattern ensures that all possible response types are handled explicitly and safely .

Foundations of State Machines

What Are Finite State Machines?

finite state machine (FSM) is a mathematical model of computation that represents a system with a finite number of states, transitions between those states, and actions in response to inputs. The core concept is that the machine can only be in one state at any given time, and state transitions are well-defined and predictable .

FSMs consist of several key components:

  • States: The distinct configurations the system can be in
  • Transitions: The rules defining how the system moves from one state to another
  • Events: The inputs that trigger state transitions
  • Actions: The operations performed when entering, exiting, or during states

The Problem: Managing Complex State Transitions

Modern applications often involve complex state management where certain actions are only valid in specific states. Consider a document management system where a document might be in “draft”, “under_review”, or “published” states, with strict rules about allowed transitions between these states .

Without proper modeling, state management often devolves into a collection of boolean flags and conditional logic. The Game Programming Patterns website provides an excellent example of this problem in the context of a game character:

// Problematic approach with boolean flags
class Heroine {
  isJumping_: boolean;
  isDucking_: boolean;
  isDiving_: boolean;

  handleInput(input: Input) {
    if (input == PRESS_B) {
      if (!isJumping_ && !isDucking_) {
        // Jump...
      }
    } else if (input == PRESS_DOWN) {
      if (!isJumping_) {
        isDucking_ = true;
        setGraphics(IMAGE_DUCK);
      } else {
        isJumping_ = false;
        setGraphics(IMAGE_DIVE);
      }
    } else if (input == RELEASE_DOWN) {
      if (isDucking_) {
        // Stand...
      }
    }
  }
}

This approach becomes increasingly difficult to maintain as more states are added, with bugs emerging from invalid state combinations and complex conditional logic .

Applications of State Machines

State machines find applications across numerous domains:

  • User Interface Development: Managing various states of user interface components, such as button states (enabled, disabled, highlighted) and navigation states .
  • Game Development: Controlling game states (menu, gameplay, pause, game over) and character states (idle, running, jumping) .
  • Workflow Management: Modeling business processes with defined stages and transition rules, such as order processing systems .
  • Embedded Systems: Managing device states (on, off, standby) and responding to input events predictably .
  • Network Protocols: Handling connection states, sessions, and data transmission phases .

Combining Discriminated Unions and State Machines

The Synergy Between Concepts

Discriminated unions and state machines form a powerful combination because they address complementary aspects of the state management problem. State machines provide the conceptual framework for modeling state transitions, while discriminated unions offer the type-safe implementation mechanism.

This synergy creates a development experience where invalid states become unrepresentable in the type system. The compiler can catch transition errors that would otherwise manifest as runtime bugs, significantly improving code reliability.

Core Integration Strategy

The fundamental strategy for combining these concepts involves:

  1. Representing each state as a distinct type in a discriminated union
  2. Using a common discriminant property to identify the current state
  3. Defining transitions as functions that take the current state and an event, returning a new state
  4. Leveraging exhaustive checking to ensure all states and transitions are handled

This approach captures the state machine’s structure in the type system, enabling compile-time verification of state transition logic.

Type-Safe State Transitions

With discriminated unions, state transitions become type-safe operations. The type checker can verify that:

  • Only valid events are processed in each state
  • Transitions always return valid states
  • All possible states are handled in state-dependent logic

This eliminates whole categories of state-related bugs that commonly occur in traditional approaches.

Implementation Examples

Example 1: Game Character State Machine

Let’s implement a comprehensive solution for the game character problem discussed earlier, using discriminated unions to create a type-safe state machine:

// Define the state machine using discriminated unions
type CharacterState =
  | { kind: "standing"; chargeTime?: never }
  | { kind: "jumping"; velocity: number }
  | { kind: "ducking"; chargeTime: number }
  | { kind: "diving"; velocity: number; targetX: number; targetY: number };

// Events that trigger state transitions
type CharacterEvent =
  | { type: "PRESS_B" }
  | { type: "PRESS_DOWN" }
  | { type: "RELEASE_DOWN" }
  | { type: "HIT_GROUND" }
  | { type: "CHARGE_COMPLETE" };

// State transition function
function transitionCharacterState(
  currentState: CharacterState,
  event: CharacterEvent
): CharacterState {
  // Handle state transitions based on current state and event
  switch (currentState.kind) {
    case "standing":
      switch (event.type) {
        case "PRESS_B":
          return { kind: "jumping", velocity: JUMP_VELOCITY };
        case "PRESS_DOWN":
          return { kind: "ducking", chargeTime: 0 };
        default:
          return currentState;
      }
    
    case "jumping":
      switch (event.type) {
        case "PRESS_DOWN":
          return { 
            kind: "diving", 
            velocity: DIVE_VELOCITY,
            targetX: calculateTargetX(),
            targetY: calculateTargetY()
          };
        case "HIT_GROUND":
          return { kind: "standing" };
        default:
          return currentState;
      }
    
    case "ducking":
      switch (event.type) {
        case "RELEASE_DOWN":
          return { kind: "standing" };
        case "CHARGE_COMPLETE":
          return executeSpecialAttack(currentState);
        default:
          // Continue charging when other events occur
          return currentState.kind === "ducking" ? {
            ...currentState,
            chargeTime: currentState.chargeTime + 1
          } : currentState;
      }
    
    case "diving":
      switch (event.type) {
        case "HIT_GROUND":
          return { kind: "standing" };
        default:
          return currentState;
      }
  }
}

// Update function for continuous state logic
function updateCharacterState(currentState: CharacterState): CharacterState {
  switch (currentState.kind) {
    case "ducking":
      // Increase charge time while ducking
      const newChargeTime = currentState.chargeTime + 1;
      if (newChargeTime > MAX_CHARGE) {
        return { ...currentState, chargeTime: MAX_CHARGE };
      }
      return { ...currentState, chargeTime: newChargeTime };
    
    case "jumping":
    case "diving":
      // Apply physics to moving states
      return applyPhysics(currentState);
    
    case "standing":
      // No special updates needed for standing
      return currentState;
  }
}

This implementation provides several advantages over the original boolean-based approach:

  • No invalid state combinations: The type system prevents impossible states like being both jumping and ducking simultaneously
  • Explicit state data: Each state only contains relevant data (chargeTime for ducking, velocity for jumping)
  • Compile-time exhaustiveness checking: TypeScript ensures all states are handled in the transition function
  • Clear state transitions: The state machine logic is centralized and explicit

Example 2: Order Processing System

For a business application, let’s implement an order processing state machine with discriminated unions:

// Order state machine using discriminated unions
type OrderState =
  | { status: "new"; createdAt: Date; createdBy: string }
  | { status: "processing"; processedAt: Date; processedBy: string; startedAt: Date }
  | { status: "shipped"; shippedAt: Date; trackingNumber: string; carrier: string }
  | { status: "delivered"; deliveredAt: Date; receivedBy: string }
  | { status: "cancelled"; cancelledAt: Date; reason: string; cancellationFee?: number };

// Events in the order lifecycle
type OrderEvent =
  | { type: "PROCESS"; processedBy: string }
  | { type: "SHIP"; trackingNumber: string; carrier: string }
  | { type: "DELIVER"; receivedBy: string }
  | { type: "CANCEL"; reason: string; fee?: number }
  | { type: "RETURN"; reason: string };

// State transition function
function transitionOrderState(
  currentState: OrderState,
  event: OrderEvent
): OrderState {
  switch (currentState.status) {
    case "new":
      if (event.type === "PROCESS") {
        return {
          status: "processing",
          processedBy: event.processedBy,
          processedAt: new Date(),
          startedAt: new Date()
        };
      }
      if (event.type === "CANCEL") {
        return {
          status: "cancelled",
          cancelledAt: new Date(),
          reason: event.reason,
          cancellationFee: event.fee
        };
      }
      break;
    
    case "processing":
      if (event.type === "SHIP") {
        return {
          status: "shipped",
          shippedAt: new Date(),
          trackingNumber: event.trackingNumber,
          carrier: event.carrier
        };
      }
      if (event.type === "CANCEL") {
        return {
          status: "cancelled",
          cancelledAt: new Date(),
          reason: event.reason,
          cancellationFee: event.fee
        };
      }
      break;
    
    case "shipped":
      if (event.type === "DELIVER") {
        return {
          status: "delivered",
          deliveredAt: new Date(),
          receivedBy: event.receivedBy
        };
      }
      break;
    
    // Delivered and cancelled states are terminal
    case "delivered":
    case "cancelled":
      // No transitions out of terminal states
      break;
  }
  
  // If no transition matches, return current state
  return currentState;
}

// Helper function to check valid transitions
function isValidTransition(
  currentState: OrderState,
  event: OrderEvent
): boolean {
  switch (currentState.status) {
    case "new":
      return event.type === "PROCESS" || event.type === "CANCEL";
    case "processing":
      return event.type === "SHIP" || event.type === "CANCEL";
    case "shipped":
      return event.type === "DELIVER";
    case "delivered":
    case "cancelled":
      return false; // Terminal states
  }
}

This order processing example demonstrates how discriminated unions can model complex business workflows with type safety. The implementation ensures that:

  • Business rules are enforced: Invalid transitions (like shipping a cancelled order) are prevented at compile time
  • State-specific data is tracked: Each state contains relevant information without unnecessary optional fields
  • Terminal states are explicit: The type system distinguishes between active and terminal states
  • Audit information is preserved: Each transition captures relevant timestamps and user information

Advanced Patterns and Best Practices

Hierarchical State Machines

As systems grow more complex, simple finite state machines may become insufficient. Hierarchical state machines allow states to contain nested substates, enabling better organization of complex state logic .

While TypeScript’s type system doesn’t directly support recursive types in all contexts, we can still model hierarchical states with careful design:

// Example of hierarchical state machine for a smart home system
type SmartHomeState =
  | { mode: "away"; security: "armed" | "disarmed" }
  | { mode: "home"; substate: HomeSubstate };

type HomeSubstate =
  | { activity: "normal"; lighting: "day" | "evening" }
  | { activity: "entertainment"; content: "music" | "movie" }
  | { activity: "sleeping"; lighting: "night"; security: "armed" };

This approach allows you to manage complexity by breaking down large state machines into manageable hierarchical components.

Exhaustiveness Checking

One of the most powerful features of discriminated unions is exhaustiveness checking—the ability to ensure that all possible cases in a union are handled. TypeScript can verify this at compile time using the never type:

// Helper function for exhaustiveness checking
function assertNever(x: never): never {
  throw new Error(`Unexpected object: ${x}`);
}

function processOrderState(state: OrderState): string {
  switch (state.status) {
    case "new":
      return "Order is new";
    case "processing":
      return "Order is being processed";
    case "shipped":
      return "Order is shipped";
    case "delivered":
      return "Order is delivered";
    case "cancelled":
      return "Order is cancelled";
    default:
      // TypeScript will error if we forget to handle any case
      return assertNever(state);
  }
}

This pattern ensures that whenever a new state is added to the OrderState union, TypeScript will flag all locations where that new state needs to be handled .

State Transition Tables

For complex state machines with many states and events, maintaining a state transition table can improve clarity and maintainability. This approach externalizes the transition logic from the code into a data structure:

// Define state transitions as a lookup table
type TransitionTable = {
  [K in OrderState["status"]]: Partial<{
    [E in OrderEvent["type"]]: (
      currentState: Extract<OrderState, { status: K }>,
      event: Extract<OrderEvent, { type: E }>
    ) => OrderState;
  }>;
};

// Implement transition logic using the table
const orderTransitions: TransitionTable = {
  new: {
    PROCESS: (state, event) => ({
      status: "processing",
      processedBy: event.processedBy,
      processedAt: new Date(),
      startedAt: new Date()
    }),
    CANCEL: (state, event) => ({
      status: "cancelled",
      cancelledAt: new Date(),
      reason: event.reason,
      cancellationFee: event.fee
    })
  },
  // ... transitions for other states
};

This table-based approach makes the state machine more declarative and easier to modify, though it requires more sophisticated TypeScript techniques .

Testing Strategies

State machines implemented with discriminated unions are particularly amenable to comprehensive testing:

// Testing state transitions
describe("Order State Machine", () => {
  it("should transition from new to processing when PROCESS event occurs", () => {
    const initialState: OrderState = {
      status: "new",
      createdAt: new Date(),
      createdBy: "user123"
    };
    
    const event: OrderEvent = {
      type: "PROCESS",
      processedBy: "employee456"
    };
    
    const newState = transitionOrderState(initialState, event);
    
    expect(newState.status).toBe("processing");
    expect((newState as any).processedBy).toBe("employee456");
  });
  
  it("should not allow transition from cancelled to processing", () => {
    const initialState: OrderState = {
      status: "cancelled",
      cancelledAt: new Date(),
      reason: "Customer request"
    };
    
    const event: OrderEvent = {
      type: "PROCESS",
      processedBy: "employee456"
    };
    
    const newState = transitionOrderState(initialState, event);
    
    // State should remain unchanged
    expect(newState.status).toBe("cancelled");
  });
});

These tests verify both positive cases (valid transitions) and negative cases (invalid transitions) to ensure the state machine behaves correctly.

Refactoring State Machines in TypeScript

A well-designed state machine is not a static artifact; it must evolve alongside the application it supports. Refactoring is an inevitable and healthy part of this process. Using discriminated unions in TypeScript provides a significant safety net, turning what would be runtime errors in other paradigms into compile-time type errors. Here’s a structured guide to refactoring your state machines effectively.

Adding a New State

This is the most common refactoring operation. The type system acts as your guide, forcing you to handle the new state everywhere it’s relevant.

Example: Let’s add a "paused" state to our game character.

// BEFORE
type CharacterState =
  | { kind: "standing" }
  | { kind: "jumping"; velocity: number }
  | { kind: "ducking"; chargeTime: number };

// AFTER
type CharacterState =
  | { kind: "standing" }
  | { kind: "jumping"; velocity: number }
  | { kind: "ducking"; chargeTime: number }
  | { kind: "paused"; // New state
      overlayVisible: boolean };

Immediately after this change, your TypeScript project will highlight errors in the transitionCharacterState and updateCharacterState functions because they are not handling the "paused" case.

Refactoring Steps:

  1. Update the Union Type: Add the new state variant to the discriminated union.
  2. Follow Type Errors: The compiler will now point to every switch statement that uses CharacterState.
  3. Implement Transition Logic: In the state transition function, add a new case "paused": and define what events are valid in this state (e.g., an "UNPAUSE" event returns the previous state, which you might need to store).
  4. Implement Update Logic: In any state-updating logic, define what happens when the character is paused (e.g., animations freeze, timers stop).

Splitting a Monolithic State

Sometimes, a state becomes too complex, holding too much unrelated data. This is a sign it should be split into more precise states.

Example: Refactoring a generic "loading" state into specific loading states.

// BEFORE - A single state trying to handle multiple scenarios
type DataState =
  | { status: "idle" }
  | { status: "loading"; dataType: "user" | "product"; progress?: number }
  | { status: "success"; data: any }
  | { status: "error"; error: string };

// AFTER - Split into specific, more descriptive states
type DataState =
  | { status: "idle" }
  | { status: "loading_user"; progress: number } // More specific, no need for `dataType`
  | { status: "loading_products"; progress: number }
  | { status: "success_user"; user: User } // Data is now type-specific
  | { status: "success_products"; products: Product[] }
  | { status: "error"; error: string; context: "user" | "products" }; // Error knows its context

Refactoring Steps:

  1. Identify the Scope: Determine the different contexts the monolithic state represents.
  2. Create New States: Define a new union with these contexts as separate states.
  3. Update Events and Transitions: You will likely need to update your event types (e.g., { type: "LOAD_USER" }) and refactor the transition logic to map to these new, more specific states.
  4. Update Consumers: Any component or function that consumes the state will now have to handle the new, more precise states, leading to clearer and more robust logic.

Consolidating Similar States

The inverse of splitting. If you find states with nearly identical logic and data, consolidating them can reduce complexity.

Example: Merging "success" and "cached" states.

// BEFORE
type DataState =
  | { status: "loading" }
  | { status: "success"; data: any; timestamp: Date }
  | { status: "cached"; data: any; timestamp: Date; source: "localStorage" };

// AFTER - Use a single state with a more descriptive property
type DataState =
  | { status: "loading" }
  | { status: "ready"; // Consolidated state
      data: any;
      timestamp: Date;
      source: "network" | "localStorage" }; // Difference is captured here

Refactoring Steps:

  1. Find Commonalities: Identify states that share the same data structure and transitions.
  2. Define a New Unified State: Create a single state that uses a new discriminant property (like source) to capture the variation.
  3. Merge Transition Logic: Update the state transition function to return the new unified state.
  4. Refactor Consumers: Update components to check the new discriminant property (e.g., state.source === "localStorage") if they need to behave differently.

Adding Context to Events

Often, the initial event design is too simplistic. Refactoring events to carry more context is common.

Example: Adding a quantity to an "ADD_TO_CART" event.

// BEFORE
type CartEvent =
  | { type: "ADD_TO_CART"; productId: string }
  | { type: "CHECKOUT" };

// AFTER
type CartEvent =
  | { type: "ADD_TO_CART"; productId: string; quantity: number } // Added context
  | { type: "CHECKOUT" };

Refactoring Steps:

  1. Update the Event Type: Add the new property to the event.
  2. Follow Type Errors: The compiler will error in every event handler that uses the "ADD_TO_CART" event.
  3. Update Event Handlers: Provide the necessary logic to handle the new quantity property, often by pulling it from the UI or action payload.

By following these patterns and trusting the TypeScript compiler to guide you, refactoring state machines becomes a systematic and far less error-prone process.


Common Pitfalls in State Management and How to Avoid Them

While state machines bring order to chaos, several common pitfalls can undermine their effectiveness. Recognizing and avoiding these traps is crucial for building truly robust applications.

The Boolean Explosion

The Pitfall: Managing state with multiple independent boolean flags (e.g., isLoadingisErrorisSuccess). This inevitably leads to invalid state combinations that the type system cannot prevent, such as { isLoading: true, isSuccess: true }.

The Solution:
Use a single discriminated union to represent mutually exclusive states.

// PITFALL
interface ComponentState {
  isLoading: boolean;
  isError: boolean;
  errorMessage?: string;
  data?: any;
}

// SOLUTION
type ComponentState =
  | { status: "idle" }
  | { status: "loading" }
  | { status: "error"; errorMessage: string }
  | { status: "success"; data: any };

Implicit State Modeling

The Pitfall: Storing state indirectly in a combination of variables and relying on application logic to infer the current state. For example, having a data object and an error object and assuming that if error is null, the application is in a success state. This is fragile and non-obvious.

The Solution:
Make state explicit. The state value should be the single source of truth for the situation of the component or system.

// PITFALL
const [data, setData] = useState(null);
const [error, setError] = useState(null);
// What is the state? It's implicit. Is it loading, success, or error?

// SOLUTION
const [state, setState] = useState<DataState>({ status: 'idle' });
// The state is explicit and clear.

Ignoring Transition Side Effects

The Pitfall: Performing side effects (like API calls or navigation) directly within the state transition function. This makes the state machine impure, difficult to test, and tightly coupled to its environment.

The Solution:
Keep the transition function pure. It should only calculate the next state based on the current state and an event. Side effects should be handled by the surrounding “machine” (e.g., a React hook or a manager class) by inspecting the state that is returned.

// PITFALL
function transition(state, event) {
  switch (state.status) {
    case "idle":
      if (event.type === "FETCH") {
        api.fetchData().then((data) => { // <-- Side effect inside transition!
          // ... update state with data
        });
        return { status: "loading" };
      }
      return state;
    // ...
  }
}

// SOLUTION
function transition(state, event) {
  switch (state.status) {
    case "idle":
      if (event.type === "FETCH") {
        // Pure transition: just return the next state.
        return { status: "loading" };
      }
      return state;
    // ...
  }
}

// The side effect is handled externally, e.g., in a React component
useEffect(() => {
  if (state.status === "loading") {
    api.fetchData().then((data) => dispatch({ type: "SUCCESS", data }));
  }
}, [state.status]);

Over-Nesting State

The Pitfall: Creating deeply nested state machines when a flat structure would suffice. Over-engineering with hierarchies too early can add unnecessary complexity.

The Solution:
Start with a flat state machine. Only introduce hierarchy (e.g., parallel or nested states) when you consistently find that multiple states share the same sub-behavior or when you need to model complex, independent domains.

Incomplete Exhaustiveness Checking

The Pitfall: Writing switch statements without a default clause or using a simple default: return state;, which can silently swallow errors when a new state is added but not handled.

The Solution:
Use the assertNever pattern for compile-time exhaustiveness checks.

function processState(state: CharacterState) {
  switch (state.kind) {
    case "standing": // ...
    case "jumping": // ...
    case "ducking": // ...
    default:
      // If a new state (e.g., "paused") is added and not handled,
      // 'state' will be of type `never` here, causing a TypeScript error.
      assertNever(state);
  }
}

Storing Derived Data in State

The Pitfall: Storing data that can be calculated from other state properties. This leads to duplication and the risk of the derived data falling out of sync with its sources (e.g., storing both items and totalCount in state).

The Solution:
Calculate derived state on the fly, typically in a custom hook or a selector.

// PITFALL
type CartState = {
  items: CartItem[];
  totalCount: number; // This can be derived!
  totalPrice: number; // This can be derived!
};

// SOLUTION
type CartState = {
  items: CartItem[];
};

// Derive the values when needed
function useCart() {
  const [state, setState] = useState<CartState>({ items: [] });
  const totalCount = state.items.reduce((sum, item) => sum + item.quantity, 0);
  const totalPrice = state.items.reduce((sum, item) => sum + item.price * item.quantity, 0);

  return { state, totalCount, totalPrice };
}

By being aware of these pitfalls and adopting the corresponding solutions, you can avoid common sources of bugs and complexity, ensuring your state machines remain robust, maintainable, and scalable as your application grows.

Conclusion

Discriminated unions and state machines form a powerful combination for managing complex application logic in a type-safe, maintainable manner. By representing states as distinct types in a union and using a common discriminant property, we can leverage the type system to eliminate whole categories of state-related bugs.

The key benefits of this approach include:

  • Compile-time safety: Invalid states and transitions are caught during development rather than in production
  • Explicit modeling: The state machine structure is clearly documented in the type system
  • Maintainability: Adding new states or transitions requires updates to all handling code, preventing inconsistencies
  • Runtime reliability: Well-defined state transitions eliminate unpredictable behaviors

As applications continue to grow in complexity, Type-Safe State Machines become increasingly valuable. Whether you’re building game characters, business workflows, user interfaces, or embedded systems, combining discriminated unions with state machine patterns will lead to more robust and maintainable software.

The initial investment in modeling your application state as Type-Safe State Machines pays dividends throughout the development lifecycle—from fewer runtime errors to easier refactoring and clearer communication of business rules. As type systems in popular languages continue to evolve, these patterns will only become more powerful and accessible to developers across all domains.