Skip to Content
Course content

174: Practice Exercise: Modeling a State Machine with Discriminated Unions

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

I've spent a lot of time reviewing PRs where developers try to manage complex application states using a single, large object. It usually starts innocently enough—you have a few flags and a status string—but as the business logic grows, these objects become "Franken-types." You end up with an interface where half the properties are optional because they only exist in certain states. That's a recipe for a runtime crash that's incredibly hard to debug.

The Danger of the "Everything-Optional" Object

Let's look at a payment processing flow. You might be tempted to model it like this:

type PaymentState = {
  status: 'idle' | 'processing' | 'success' | 'error';
  transactionId?: string;
  receiptUrl?: string;
  errorMessage?: string;
};

At first glance, this looks clean. But here is the problem: the type system has no idea that receiptUrl is only relevant when the status is 'success'. If I'm writing a function to display the receipt, I have to use a non-null assertion or a manual check every single time, even if I've already checked that the status is 'success'. Worse, nothing stops me from accidentally creating an object with status: 'idle' that also contains an errorMessage. I've just created a state that should be logically impossible, but the compiler is perfectly happy with it.

Making Impossible States Unrepresentable

The professional way to handle this is to treat each state as its own distinct type and then combine them into a union. This is where discriminated unions really shine. Instead of one giant object with optional fields, we define exactly what data is required for each specific state.

type Idle = { status: 'idle' };

type Processing = { 
  status: 'processing'; 
  transactionId: string; 
};

type Success = { 
  status: 'success'; 
  transactionId: string; 
  receiptUrl: string; 
};

type Failure = { 
  status: 'error'; 
  errorMessage: string; 
};

type PaymentState = Idle | Processing | Success | Failure;

Now, the status property acts as the "discriminant." When you check state.status === 'success', TypeScript doesn't just know the status; it effectively "narrows" the type of the entire object. Inside that if block, receiptUrl is no longer optional—it's guaranteed to be a string. You've moved the validation from a runtime "hope it's there" to a compile-time guarantee.

Letting the Compiler Do the Heavy Lifting

The real magic happens when you use a switch statement to handle these states. If you use a discriminated union, you can ensure you've handled every possible scenario. I personally love pairing this with a never check at the end of the switch. It ensures that if a junior dev adds a 'refunded' state to the union six months from now, the code won't even compile until they've added the logic to handle that new state in every single switch block across the app.

You aren't just writing types for documentation anymore; you're building a safety rail that makes it physically impossible to write a bug where you try to access a receipt for a payment that failed.




📋 Practical Task

Exercise: Refactoring a Video Upload State Machine

You've inherited a codebase for a video sharing platform. The current UploadState is a "Franken-type" that is causing bugs because developers are accessing properties that don't exist in the current state.

Current Buggy Implementation:

type UploadState = {
  status: 'idle' | 'uploading' | 'processing' | 'completed' | 'failed';
  progress?: number;
  videoId?: string;
  error?: string;
};

Your Goal: Refactor this into a Discriminated Union. Ensure that:

  • The 'uploading' state requires progress (number).
  • The 'processing' state requires progress (number) and videoId (string).
  • The 'completed' state requires videoId (string).
  • The 'failed' state requires error (string).
  • The 'idle' state requires no extra properties.

After defining the union, write a function called getUploadMessage(state: UploadState): string that uses a switch statement to return a user-friendly message (e.g., "Uploading... 40% complete" or "Error: File too large"). Ensure that the function is type-safe and that you can access the state-specific properties without using optional chaining or type assertions.

Rating
0 0

There are no comments for now.

to be the first to leave a comment.