-
Section 1: Getting Started
-
Section 2: Basic Types
-
Section 3: Functions and Objects
-
Section 4: Advanced Types
-
Section 5: Object-Oriented TypeScript
-
Section 6: Working with Modules
-
Section 7: Tooling and Practice
-
Section 8: Type-Level Programming
-
Section 9: TypeScript with Backends
-
Section 10: Testing Typed Code
-
Section 11: Data Validation with Types
-
Section 12: Practical Projects
-
Section 13: Compiler Internals
-
Section 14: Configuration Deep Dive
-
Section 15: Enums, Symbols, and Special Types
-
Section 16: Working with Async Code
-
Section 17: TypeScript and the DOM
-
Section 18: Advanced Generics Practice
-
Section 19: Working with Third-Party Types
-
Section 20: Monorepo and Large-Scale Practices
-
Section 21: Common Pitfalls and Best Practices
-
Section 22: Interview Practice
-
Section 23: Handbook: Narrowing In Depth
-
Section 24: Handbook: Object Types In Depth
-
Section 25: Handbook: Classes In Depth
-
Section 26: Handbook: Modules In Depth
-
Section 27: Handbook: Declaration Files In Depth
-
Section 28: JSX and Namespaces
-
Section 29: Compiler Configuration Reference
-
Section 30: More Practice Exercises
-
Section 31: Handbook: Everyday Types Deep Dive
-
Section 32: Utility Types Full Reference
-
Section 33: Decorators Reference
-
Section 34: Mixins and Advanced OOP Patterns
-
Section 35: Iterators and Generators Typing
-
Section 36: More Type-Level Programming Practice
-
Section 37: TypeScript Ecosystem Tools
-
Section 38: TypeScript with Testing Frameworks
-
Section 39: TypeScript for Library Authors
-
Section 40: More Interview Practice
-
Section 41: Handbook: Functions In Depth
-
Section 42: Handbook: Type Manipulation Deep Dive
-
Section 43: More Real-World Patterns
-
Section 44: TypeScript Release Notes Highlights
-
Section 45: Final Practice and Review
174: Practice Exercise: Modeling a State Machine with Discriminated Unions
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 requiresprogress(number). - The
'processing'state requiresprogress(number) andvideoId(string). - The
'completed'state requiresvideoId(string). - The
'failed'state requireserror(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.
There are no comments for now.