Skip to Content
Course content

244: Typing a Multi-Step Wizard Form State

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

I've seen this pattern in almost every mid-sized React or Vue project I've joined. You're building a multi-step onboarding wizard, and the instinct is to create one big "God Object" to hold all the form data. It feels intuitive at first, but it usually leads to a codebase littered with non-null assertions (the ! operator) and defensive if checks that shouldn't need to exist.

// The "I'll just make everything optional" approach
interface OnboardingState {
  step: 1 | 2 | 3;
  // Step 1 data
  username?: string;
  email?: string;
  // Step 2 data
  plan?: 'free' | 'premium';
  billingCycle?: 'monthly' | 'yearly';
  // Step 3 data
  cardNumber?: string;
  cvv?: string;
}

const state: OnboardingState = { step: 3 };

// Even though we are on Step 3, TypeScript has no idea 
// that Step 1 and 2 are already complete.
function submitForm(state: OnboardingState) {
  // Error: Object is possibly 'undefined'
  console.log(`Sending ${state.username} to the database...`); 
  
  // This "fix" is a ticking time bomb
  console.log(`Plan selected: ${state.plan!.toUpperCase()}`); 
}

The Optional Property Trap

The problem here is that our type definition is too loose. By marking everything as optional, we're telling TypeScript, "At any given moment, any of these fields might be missing." While that's technically true for the entire lifecycle of the wizard, it's not true for the specific state of the wizard at a specific step.

When the user is on Step 3, you know that the username and plan have already been collected. But because we used a single interface with optional properties, TypeScript can't track the progression of data. You end up fighting the compiler, and eventually, you start using ! just to make the red lines go away. That's how bugs slip into production—you're telling the compiler to shut up instead of telling it how the data actually flows.

Modeling State as a Discriminated Union

The fix is to stop thinking of the state as one object and start thinking of it as a set of possible states. We can use a Discriminated Union to tie the step number directly to the data that must exist at that step.

type Step1State = {
  step: 1;
  username: string;
  email: string;
};

type Step2State = {
  step: 2;
  username: string;
  email: string;
  plan: 'free' | 'premium';
  billingCycle: 'monthly' | 'yearly';
};

type Step3State = {
  step: 3;
  username: string;
  email: string;
  plan: 'free' | 'premium';
  billingCycle: 'monthly' | 'yearly';
  cardNumber: string;
  cvv: string;
};

type WizardState = Step1State | Step2State | Step3State;

function submitForm(state: WizardState) {
  // Now we narrow the type using the 'step' discriminator
  if (state.step === 3) {
    // Inside this block, TypeScript knows exactly which fields are present.
    // No more optional chaining or non-null assertions!
    console.log(`Sending ${state.username} with plan ${state.plan} to the API.`);
  }
}

I'll admit, this feels like more boilerplate because you're repeating fields (like username and email) across the types. However, this is a trade-off I'll take every single time. You've moved the "truth" of your application from a developer's mental note ("I think we collected the email in step 1") into the type system itself.

If you really hate the repetition, you can use intersection types to build these incrementally, but the core concept remains: your state should be a union of specific steps, not a single object of optional values.




📋 Practical Task

Implementing a Flight Booking State Machine

You are building a flight booking wizard. The process has three distinct stages:

  • Step 1 (Search): Requires origin and destination (both strings).
  • Step 2 (Seat Selection): Requires everything from Step 1, plus a seatNumber (string) and class ('economy' | 'business' | 'first').
  • Step 3 (Payment): Requires everything from Step 2, plus a paymentMethod ('credit_card' | 'paypal').

Your Task:

  1. Create a Discriminated Union called BookingState that represents these three steps.
  2. Write a function called finalizeBooking that accepts BookingState as an argument.
  3. Inside finalizeBooking, use a type guard (like an if or switch statement) to ensure the code only attempts to access the paymentMethod if the state is specifically at step 3.
  4. Ensure that you do not use any optional properties (?) or non-null assertions (!) in your solution.
Rating
0 0

There are no comments for now.

to be the first to leave a comment.