Prevent Impossible States Pt. 2 - State Transitions
Model a timer's states and events with TypeScript unions, then define the rules for moving between them.
This post builds on discriminated unions. They let us describe valid state shapes, but on their own, they don’t define which state transitions are valid.
To define those rules, we can use a finite state machine (FSM).1
If you’ve ever seen a traffic light, then you’re already acquainted with FSMs. Consider a simple traffic light and its three states:
- Green (Go)
- Yellow
- Red
It’s called a finite state machine because the model has a fixed, finite set of states. We can then use this knowledge to define which state transitions are allowed.
The best way to learn is by doing, so let’s create our own finite state machine: a timer with four statuses, idle, running, paused, and finished.
Below we have the events and their corresponding state transitions:
| Event | From | To |
|---|---|---|
| Start | idle | running |
| Pause | running | paused |
| Resume | paused | running |
| Tick | running | running while time remains, or finished when the last second elapses |
| Reset | any state | idle |
The impact of an event depends on the current state. Dispatching a start event to an idle timer moves its status to running. A pause event moves a running timer to paused.
Defining states and events
To model our timer, we’ll create two tagged unions.
The first, TimerState, describes our timer’s status and its data at a specific point in time. The second, TimerEvent, defines the events we can send to it.
type TimerState =
| { status: 'idle' }
| { status: 'running'; remainingSeconds: number }
| { status: 'paused'; remainingSeconds: number }
| { status: 'finished' };
type TimerEvent =
| { type: 'start'; durationSeconds: number }
| { type: 'pause' }
| { type: 'resume' }
| { type: 'tick' }
| { type: 'reset' };
We’ll use both unions together to create our state machine, with a strict set of rules for transitioning between states.
In our model, remainingSeconds is required when a timer’s status is running or paused, and it isn’t part of the other cases. The object literals below therefore produce type errors.
// @ts-expect-error an idle timer has no remaining time
const bad: TimerState = { status: 'idle', remainingSeconds: 5 };
// @ts-expect-error a running timer must say how much time is left
const bad2: TimerState = { status: 'running' };
Modeling enforceable transitions
An idle state with a pause event results in nothing happening. The transition function just returns the current state, because start is the only event that moves an idle timer into another status. Our rules are listed below.
| Current state | Event | Result |
|---|---|---|
idle | start | running, with remainingSeconds taken from the event’s durationSeconds |
running | pause | paused, keeping remainingSeconds |
paused | resume | running, keeping remainingSeconds |
running | tick | running with one second less, or finished when the last second elapses |
| any | reset | idle |
| anything else | unchanged |
Our function accepts any TimerState with any TimerEvent, so the implementation has to enforce the transition rules we’ve defined.
Writing the transition function
With our data modeled, we can describe our transition function using mathematical notation:
We use δ (delta) to represent our transition function. It takes two inputs: a current state from the set S and an event from the set E. The × groups those inputs into a state–event pair, and → S tells us that the function returns a timer state.
For example, a running timer with two seconds remaining and a tick event produces a running timer with one second remaining. The returned state can also be unchanged, as when an idle timer receives a pause event.
In TypeScript, we express the same signature like this:
type Transition = (state: TimerState, event: TimerEvent) => TimerState;
We can use Transition to type our implementation:
const transition: Transition = (state, event) => {
if (event.type === 'reset') {
return { status: 'idle' };
}
switch (state.status) {
case 'idle':
return event.type === 'start'
? { status: 'running', remainingSeconds: event.durationSeconds }
: state;
case 'running':
switch (event.type) {
case 'pause':
return { status: 'paused', remainingSeconds: state.remainingSeconds };
case 'tick':
return state.remainingSeconds > 1
? { status: 'running', remainingSeconds: state.remainingSeconds - 1 }
: { status: 'finished' };
default:
return state;
}
case 'paused':
return event.type === 'resume'
? { status: 'running', remainingSeconds: state.remainingSeconds }
: state;
case 'finished':
return state;
default: {
const unreachable: never = state;
return unreachable;
}
}
};
In JavaScript land, we’re used to finishing a reducer with default: return state. In the outer switch here, though, we’re checking state.status, so we can use never to ensure that every status in TimerState has a corresponding case. If we add a status without handling it, TypeScript reports a type error.
Inside our tick case, the condition state.remainingSeconds > 1 determines whether the timer has more than one second left. If true, the next TimerState keeps the running status, with remainingSeconds decremented by one. When remainingSeconds is 1, the function returns a new TimerState with status: 'finished'.
We’re also isolating our timer logic from the clock, which lends itself well to testing.
const idle: TimerState = { status: 'idle' };
const running = transition(idle, { type: 'start', durationSeconds: 2 });
// { status: 'running', remainingSeconds: 2 }
const paused = transition(running, { type: 'pause' });
// { status: 'paused', remainingSeconds: 2 }
const resumed = transition(paused, { type: 'resume' });
// { status: 'running', remainingSeconds: 2 }
const finished = transition(transition(resumed, { type: 'tick' }), { type: 'tick' });
// { status: 'finished' }
const stillIdle = transition(idle, { type: 'pause' });
// { status: 'idle' } — pause doesn't apply to an idle timer, so nothing changes
// @ts-expect-error 'start' requires durationSeconds
transition(idle, { type: 'start' });
Inputs and UI actions
The number type still accepts values such as -30, so we need a runtime check to enforce a positive timer duration.
The UI should only offer the option to pause when the timer is running. If pause is still sent while it’s idle, returning the current state is the behavior we intended.
The lesson
TypeScript helps us catch invalid state shapes and missing cases. Our transition function makes sure the timer only follows the valid state transitions we’ve defined.
Footnotes
-
For historical background, see Michael O. Rabin and Dana Scott, Finite Automata and Their Decision Problems (1959). ↩