1. The Problem with String States#
Imagine you are building a video player component. It has exactly 4 states: Idle, Playing, Paused, and Stopped.
The business logic dictates strict transition rules:
- From
Idle, you can only go toPlaying. - From
Playing, you can go toPausedorStopped. - From
Paused, you can go toPlayingorStopped. - From
Stopped, you can go back toIdle.
If we model this using a simple union of strings, the compiler lets us transition from Idle directly to Stopped, which violates the business rules!
type State = "Idle" | "Playing" | "Paused" | "Stopped";
function transition(currentState: State, nextState: State) {
// Runtime logic...
}
// 🔴 BAD: The compiler allows this completely invalid transition!
transition("Idle", "Stopped");
To fix this safely, we usually have to write defensive if statements inside the function and throw runtime errors. But in TypeScript, we can do better.
2. Modeling Transitions in the Type System#
We can map every possible state to its strictly allowed transition targets using a structural interface.
type VideoMachine = {
Idle: "Playing";
Playing: "Paused" | "Stopped";
Paused: "Playing" | "Stopped";
Stopped: "Idle";
};This interface acts as the “Rulebook” for our Finite State Machine. If you are on the Playing key, the type system tells you that your only valid values are "Paused" | "Stopped".
3. Enforcing the Machine#
Now, we write a highly generic transition function that uses our VideoMachine mapping to constrain the allowed nextState.
// 1. TState must be a valid key in the machine.
// 2. TNext must extend the value corresponding to TState in the machine!
function transition<
TState extends keyof VideoMachine,
TNext extends VideoMachine[TState]
>(currentState: TState, nextState: TNext) {
console.log(`Transitioning from ${currentState} to ${nextState}`);
}The Magic of Impossible States#
When we call this function, the TypeScript compiler evaluates the transition at compile time!
// 🟢 Valid Transitions
transition("Idle", "Playing");
transition("Playing", "Paused");
transition("Paused", "Stopped");
// 🔴 INVALID Transitions (Caught by Compiler!)
// transition("Idle", "Stopped");
// Error: Argument of type '"Stopped"' is not assignable to parameter of type '"Playing"'.
// transition("Paused", "Idle");
// Error: Argument of type '"Idle"' is not assignable to parameter of type '"Playing" | "Stopped"'.
By moving state transition logic into the Type System, you completely eliminate an entire class of runtime bugs. If a developer tries to dispatch an invalid action to a Redux store or XState machine, their code will simply not compile.
This pattern is the exact foundational architecture used by powerful state management libraries like XState.
Summary & Next Steps#
In this episode:
- We identified the limitations of modeling states with generic string unions.
- We modeled Finite State Machine (FSM) rules strictly inside a structural interface.
- We wrote a generic function that enforces
TNext extends Machine[TState]to physically prevent impossible state transitions.
In Episode 52: Variance (Covariance and Contravariance), we dive into the theoretical computer science underpinnings of TypeScript’s type compatibility engine!

