Tech Verse Logo
Enable dark mode
Strict TypeScript Discriminated Unions for React Forms

Strict TypeScript Discriminated Unions for React Forms

Md. Mostafijur RahmanMMd. Mostafijur Rahman

Md. Mostafijur Rahman

•4 min read

Multi-step forms break down when your state model consists of one large object filled with optional fields. You end up with impossible states, such as a payment step trying to read billing tokens before the user has even selected a shipping provider. Type assertions and optional chaining hide these bugs until runtime. If you want compile-time safety, you need to model your form state as a discriminated union. It isn't just about cleaner code; it's about making invalid states unrepresentable in your application.

Instead of letting any step access any field, you define each step as a separate interface sharing a common literal tag. TypeScript uses this tag to narrow the type automatically inside your conditional rendering blocks. You don't need to write manual type guards or cast objects with as unknown as TargetType. When you check the step identifier, TypeScript knows precisely which properties exist on the state object. You'll catch missing fields during compilation rather than tracking down production errors caused by undefined values sneaking into your payload.

Most developers throw their hands up and use a giant interface with optional properties for every field across every step. That approach defeats the purpose of static typing. It forces you to write defensive checks everywhere, and you'll constantly forget whether a field is guaranteed to exist or if it's just undefined because the user hasn't reached that screen yet. Discriminated unions solve this entirely by coupling data strictly to the current step identifier.

Designing the Discriminated Union State Model

Let's start by defining the shape of our form steps. We have an initial details step, a shipping step that requires specific carrier data, and a final payment step that needs credit card tokens. Each state variant carries only the data relevant to its stage in the process. You don't want payment details floating around in memory while the user is still filling out their mailing address.

type FormStep = 
  | { step: 'details'; name: string; email: string }
  | { step: 'shipping'; name: string; email: string; carrier: 'ups' | 'fedex'; address: string }
  | { step: 'payment'; name: string; email: string; carrier: 'ups' | 'fedex'; address: string; cardNumber: string };

Notice how fields persist or change as the union transitions. If you want to share data across all steps, you include those properties in every variant. Alternatively, you can separate global form values from step-specific validation payloads. For a deeper look at managing related state shapes without falling back to clumsy reducers, check out React useReducer: When useState Stops Scaling. It's easy to bloat your state container if you don't establish clear boundaries early.

When you map out these types, you're essentially building a finite state machine directly into your type definitions. TypeScript enforces that you can't sneak a cardNumber into the initial details step. If you try, the compiler complains immediately. That's the exact feedback loop you want when building complex interactive interfaces where state bugs usually hide in edge cases.

Handling State Transitions Safely

When updating the form state, you want your handler functions to enforce the rules of your union. If a user is on the shipping step, they shouldn't be able to submit payment details because the state transition function should only accept the payload required for the next valid phase. Here is how you implement a reducer or setter function that respects these boundaries.

import { useState } from 'react';

export function useMultiStepForm() {
  const [state, setState] = useState<FormStep>({
    step: 'details',
    name: '',
    email: '',
  });

  const goToShipping = (carrier: 'ups' | 'fedex', address: string) => {
    setState((current) => {
      if (current.step !== 'details') {
        throw new Error('Invalid transition');
      }
      return {
        step: 'shipping',
        name: current.name,
        email: current.email,
        carrier,
        address,
      };
    });
  };

  return { state, goToShipping };
}

By checking current.step !== 'details', we satisfy TypeScript's narrowing rules. If someone tries to call goToShipping from an invalid state, our runtime check catches it, while TypeScript ensures that current.name and current.email are safely preserved from the previous step without requiring nullable checks. You don't have to guess whether those fields are populated because the type system guarantees they arrived from the previous valid state transition.

Writing these state transition functions requires discipline. You'll find yourself writing explicit guard clauses for every step change. That's a feature, not a bug. It forces you to think about how data flows through your application instead of blindly spreading arbitrary objects into global state stores.

Rendering Step Components Without Type Casts

When it comes time to render inputs based on the current step, your JSX becomes straightforward. TypeScript narrows the state variable inside each conditional block. If you try to access state.carrier inside a block where state.step is 'details', the compiler halts the build immediately.

export function FormContainer() {
  const { state, goToShipping } = useMultiStepForm();

  return (
    <div>
      {state.step === 'details' && (
        <div>
          <p>Step One: Details</p>
          <input value={state.name} readOnly />
        </div>
      )}
      {state.step === 'shipping' && (
        <div>
          <p>Step Two: Shipping via {state.carrier}</p>
          <input value={state.address} readOnly />
        </div>
      )}
    </div>
  );
}

This pattern eliminates the defensive programming styles where every input value is prefixed with optional chaining or fallback empty strings. For more architectural patterns on keeping your components clean and typed, read through React TypeScript Props: Unions, Generics, and Children. You'll quickly notice how much boilerplate disappears when your types match your UI states.

Refactoring form state to use discriminated unions requires upfront design work. You have to map out every valid transition before writing component logic. The payoff arrives the first time you refactor a form field and watch the compiler point out every single place where your components failed to account for a state change. It's a massive productivity boost once you get past the initial setup.

Md. Mostafijur RahmanMMd. Mostafijur Rahman

WRITTEN BY

Md. Mostafijur Rahman

    Latest Posts

    View All

    Isolating Server Components in React and Next Applications

    Isolating Server Components in React and Next Applications

    Securing Daemons with Systemd Dynamic Users

    Securing Daemons with Systemd Dynamic Users

    Strict Null Safety With Pydantic V2 Validators

    Strict Null Safety With Pydantic V2 Validators

    Diagnosing Async Python Event Loop Stalls

    Diagnosing Async Python Event Loop Stalls

    Synchronizing State Across Parallel Next.js Routes

    Synchronizing State Across Parallel Next.js Routes

    LLM API Circuit Breaker Pattern

    LLM API Circuit Breaker Pattern

    Strict TypeScript Discriminated Unions for React Forms

    Strict TypeScript Discriminated Unions for React Forms

    Fixing Next.js Server Action Timeout Errors

    Fixing Next.js Server Action Timeout Errors

    Resilient Polling Loops with React and AbortController

    Resilient Polling Loops with React and AbortController

    Preventing Laravel Queue Worker Memory Exhaustion

    Preventing Laravel Queue Worker Memory Exhaustion