0
0
Typescriptprogramming~15 mins

Discriminated union state machines in Typescript - Deep Dive

Choose your learning style9 modes available
Overview - Discriminated union state machines
What is it?
Discriminated union state machines use TypeScript's special type feature called discriminated unions to represent different states of a system clearly and safely. Each state is an object with a unique 'tag' property that tells which state it is. This helps programmers write code that handles each state correctly without mistakes. It is like having a clear map of all possible conditions a system can be in.
Why it matters
Without discriminated union state machines, managing different states in a program can become confusing and error-prone, especially as systems grow complex. Mistakes like handling the wrong state or missing a state case can cause bugs that are hard to find. Using discriminated unions makes the code safer and easier to understand, reducing bugs and improving reliability. This is important in real-world apps like user interfaces, games, or network connections where states change often.
Where it fits
Before learning this, you should understand basic TypeScript types, interfaces, and union types. After mastering discriminated union state machines, you can explore advanced state management libraries, reactive programming, or formal verification of state machines.
Mental Model
Core Idea
A discriminated union state machine is a set of states each tagged with a unique label, letting TypeScript know exactly which state is active so it can check your code handles all cases safely.
Think of it like...
Imagine a traffic light with three colored lights: red, yellow, and green. Each color is a unique state with a clear label. You always know which light is on, and you act accordingly. The discriminated union is like the traffic light's color label telling you the current state.
┌───────────────┐
│ State Machine │
├───────────────┤
│ { type: 'A' } │
│ { type: 'B' } │
│ { type: 'C' } │
└──────┬────────┘
       │
       ▼
  Handle each state
  with a switch on 'type'
Build-Up - 7 Steps
1
FoundationUnderstanding basic union types
🤔
Concept: Learn what union types are and how they let a variable hold one of several types.
In TypeScript, a union type means a value can be one of several types. For example, let x: string | number means x can be a string or a number. This lets you write flexible code that accepts multiple types but still keeps track of which one it is.
Result
You can declare variables that accept multiple types and use type checks to handle each type safely.
Understanding union types is the foundation for discriminated unions because they combine multiple types into one variable.
2
FoundationIntroducing tagged object types
🤔
Concept: Learn how to add a unique 'tag' property to each object type to identify it.
Instead of just having different shapes, we add a common property like 'type' with a unique string literal value to each object. For example, { type: 'loading' } and { type: 'success', data: string }. This 'type' property is the tag that tells us which object shape we have.
Result
You can now distinguish objects by checking their 'type' property, making it easier to write safe code.
Adding a tag property creates a clear way to tell different states apart, which is key for safe state handling.
3
IntermediateCreating discriminated unions
🤔Before reading on: do you think TypeScript can automatically know which object type you have just by checking the tag property? Commit to your answer.
Concept: Combine tagged object types into a union so TypeScript can discriminate between them based on the tag.
We create a union type like type State = { type: 'idle' } | { type: 'loading' } | { type: 'success', data: string }. TypeScript uses the 'type' property to know exactly which shape is active. This is called a discriminated union.
Result
TypeScript narrows the type automatically when you check the 'type' property, enabling safe access to properties specific to each state.
Knowing that TypeScript uses the tag to narrow types lets you write code that handles each state without errors or extra checks.
4
IntermediateWriting safe state handling code
🤔Before reading on: do you think a switch statement on the tag property can catch all states and warn you if you miss one? Commit to your answer.
Concept: Use switch or if statements on the tag to handle each state explicitly, letting TypeScript check completeness.
When you write switch(state.type) { case 'idle': ... case 'loading': ... case 'success': ... }, TypeScript knows all possible cases. If you forget one, it can warn you. This prevents bugs from missing states.
Result
Your code safely handles all states, and TypeScript helps catch mistakes before running the program.
Using discriminated unions with switch statements creates a safety net that prevents missing or wrong state handling.
5
IntermediateExtending states with data
🤔Before reading on: do you think states can carry extra information safely with discriminated unions? Commit to your answer.
Concept: States can have extra properties specific to them, and TypeScript keeps track of these safely.
For example, a 'success' state can have a 'data' property with the result, while an 'error' state can have an 'errorMessage'. TypeScript knows which properties exist based on the tag, so you only access them when safe.
Result
You can model complex states with extra data without losing type safety.
Knowing that discriminated unions support extra data per state lets you build rich, safe state machines.
6
AdvancedModeling state transitions safely
🤔Before reading on: do you think discriminated unions alone prevent invalid state changes? Commit to your answer.
Concept: Combine discriminated unions with functions that only allow valid state transitions to enforce correct flows.
You can write functions that accept a state and return a new state, but only allow certain transitions. For example, from 'loading' you can go to 'success' or 'error', but not back to 'idle'. TypeScript can help check these rules with careful typing.
Result
Your state machine not only represents states but also enforces valid moves between them.
Understanding how to type transitions prevents bugs from invalid state changes in complex systems.
7
ExpertAdvanced patterns and pitfalls in state machines
🤔Before reading on: do you think adding too many states or complex transitions can make discriminated unions hard to maintain? Commit to your answer.
Concept: Explore how large or deeply nested state machines can become complex and how to manage that complexity with modular types and helper functions.
When state machines grow, discriminated unions can become large and hard to read. Experts split states into smaller unions, use helper functions to handle transitions, and sometimes combine with libraries for visualization or runtime checks. Also, beware of overlapping tags or inconsistent tags that break type safety.
Result
You learn how to keep state machines maintainable and safe even as complexity grows.
Knowing the limits and best practices of discriminated unions helps you build scalable, robust state machines.
Under the Hood
TypeScript uses the unique literal type of the 'tag' property to narrow the union type at compile time. When you check the tag, the compiler knows exactly which object shape you have and what properties are safe to access. This narrowing happens through control flow analysis, enabling safe property access and exhaustive checks.
Why designed this way?
Discriminated unions were designed to combine the flexibility of union types with the safety of tagged variants, inspired by similar features in functional languages like Elm or Haskell. This design avoids runtime overhead by doing checks at compile time and prevents common bugs from incorrect type assumptions.
┌───────────────┐
│ Discriminated │
│   Union Type  │
├───────────────┤
│ { type: 'A' } │
│ { type: 'B' } │
│ { type: 'C' } │
└──────┬────────┘
       │
       ▼
  switch(state.type) {
    case 'A': // state is { type: 'A' }
    case 'B': // state is { type: 'B' }
    case 'C': // state is { type: 'C' }
  }
Myth Busters - 4 Common Misconceptions
Quick: Does TypeScript check at runtime if the tag property matches the actual object shape? Commit to yes or no.
Common Belief:TypeScript enforces the tag property at runtime to ensure the object matches the declared state.
Tap to reveal reality
Reality:TypeScript only checks types at compile time and does not enforce any checks at runtime. The tag property is a convention for the compiler, but runtime values can still be incorrect.
Why it matters:Assuming runtime checks exist can lead to bugs if invalid objects are passed, causing unexpected behavior or runtime errors.
Quick: Can you use any property as a tag for discriminated unions? Commit to yes or no.
Common Belief:Any property can be used as a tag for discriminated unions as long as it is unique per state.
Tap to reveal reality
Reality:The tag property must be a literal type (like a string literal) and common to all union members for TypeScript to discriminate correctly.
Why it matters:Using non-literal or inconsistent tags breaks type narrowing, causing loss of type safety.
Quick: Does adding more states always make the state machine better? Commit to yes or no.
Common Belief:Adding more states always improves the accuracy and safety of the state machine.
Tap to reveal reality
Reality:Too many states can make the machine complex, harder to maintain, and increase the chance of mistakes or missing transitions.
Why it matters:Overcomplicated state machines can slow development and introduce subtle bugs.
Quick: Does TypeScript automatically warn you if you forget to handle a state in a switch? Commit to yes or no.
Common Belief:TypeScript always warns if you forget a case in a switch statement over a discriminated union.
Tap to reveal reality
Reality:TypeScript only warns if you use the 'never' type or exhaustiveness checks explicitly; otherwise, missing cases can go unnoticed.
Why it matters:Missing cases without warnings can cause runtime bugs that are hard to detect.
Expert Zone
1
Discriminated unions rely on literal types for tags; subtle differences like extra spaces or casing can break type narrowing silently.
2
Combining discriminated unions with exhaustive type checking patterns (like 'never' checks) greatly improves safety but requires careful coding discipline.
3
State machines can be composed modularly by nesting discriminated unions, but this requires advanced TypeScript features like mapped types and conditional types.
When NOT to use
Discriminated union state machines are less suitable for very large or dynamic state sets where states are not known at compile time. In such cases, runtime state machines or libraries with dynamic state handling (like XState) are better. Also, if you need runtime validation, consider combining with runtime type checkers.
Production Patterns
In production, discriminated union state machines are used in UI frameworks to manage component states, in network request handling to track loading/error/success, and in game development for character or game state. They are often combined with reducer functions and immutable state updates for predictable state management.
Connections
Algebraic Data Types (ADTs)
Discriminated unions are TypeScript's way to express ADTs, a concept from functional programming.
Understanding ADTs from functional languages helps grasp why discriminated unions provide safe, expressive state modeling.
Finite State Machines (FSMs)
Discriminated union state machines are a typed way to implement FSMs in code.
Knowing FSM theory clarifies how states and transitions map to discriminated unions and functions.
Traffic Light Systems (Real-world control systems)
Traffic lights are a classic example of a state machine with clear states and transitions.
Seeing traffic lights as state machines helps understand the importance of clear, exclusive states and safe transitions.
Common Pitfalls
#1Forgetting to handle all states in a switch statement.
Wrong approach:switch(state.type) { case 'idle': // handle idle break; case 'loading': // handle loading break; // missing 'success' case }
Correct approach:switch(state.type) { case 'idle': // handle idle break; case 'loading': // handle loading break; case 'success': // handle success break; default: const _exhaustiveCheck: never = state; return _exhaustiveCheck; }
Root cause:Not using exhaustive checks or forgetting to cover all union members leads to missing cases.
#2Using a non-literal or inconsistent tag property.
Wrong approach:type State = { status: string } | { status: string, data: string }; // 'status' is not a literal type
Correct approach:type State = { status: 'idle' } | { status: 'success', data: string }; // 'status' is a literal type
Root cause:Tags must be literal types for TypeScript to discriminate correctly.
#3Assuming TypeScript enforces runtime tag correctness.
Wrong approach:const state: State = JSON.parse(userInput); // no runtime check if(state.type === 'success') { console.log(state.data.length); }
Correct approach:function isSuccess(state: any): state is { type: 'success', data: string } { return state?.type === 'success' && typeof state.data === 'string'; } if(isSuccess(state)) { console.log(state.data.length); }
Root cause:TypeScript types are erased at runtime; runtime validation is needed for safety.
Key Takeaways
Discriminated union state machines use a unique tag property to let TypeScript safely distinguish between different states.
This approach prevents bugs by ensuring all states are handled explicitly and safely with type narrowing.
Adding extra data to states is safe because TypeScript knows which properties exist for each tagged state.
Combining discriminated unions with exhaustive checks and typed transitions creates robust, maintainable state machines.
Understanding the limits and runtime considerations is key to using discriminated unions effectively in real-world projects.