State Machines with Sealed Unions
Sealed unions are the right tool for modelling a state machine where the transitions are known at design time. The checker enforces that every state has a transition handler, and you can’t construct an illegal state.
A simple order workflow
type Order = Pending | Submitted | Paid | Shipped | Delivered | Cancelled
class Pending: items: list[Item]
class Submitted: items: list[Item] submitted_at: datetime
class Paid: items: list[Item] submitted_at: datetime paid_at: datetime payment_id: str
class Shipped: items: list[Item] paid_at: datetime tracking_no: str
class Delivered: items: list[Item] delivered_at: datetime
class Cancelled: items: list[Item] reason: strEach state carries the fields it needs. Paid has payment_id; Shipped has tracking_no. You cannot construct a Shipped order without a tracking number — the type system enforces that.
Transitions
type TransitionError = AlreadySubmitted | AlreadyPaid | NotPaid | NotShipped
class AlreadySubmitted: passclass AlreadyPaid: passclass NotPaid: passclass NotShipped: pass
def submit(o: Order) -> Result[Submitted, TransitionError]: match o: case Pending(items): return Ok(Submitted(items=items, submitted_at=datetime.now())) case _: return Err(AlreadySubmitted())
def pay(o: Order, payment_id: str) -> Result[Paid, TransitionError]: match o: case Submitted(items, submitted_at): return Ok(Paid( items=items, submitted_at=submitted_at, paid_at=datetime.now(), payment_id=payment_id, )) case _: return Err(NotPaid())
def ship(o: Order, tracking_no: str) -> Result[Shipped, TransitionError]: match o: case Paid(items, _, paid_at, _): return Ok(Shipped(items=items, paid_at=paid_at, tracking_no=tracking_no)) case _: return Err(NotPaid())Each function takes the whole Order and returns a specific state. The match is exhaustive over the union; the only legal transitions are the named ones.
Why this is better than fields-on-one-class
# Anti-patternclass Order: state: str # "pending" | "submitted" | ... payment_id: str? # set only if paid tracking_no: str? # set only if shipped ...- ❌ Nullable fields proliferate. Every consumer has to narrow.
- ❌ “Impossible states” are constructible.
Order(state="pending", payment_id="...")compiles. - ❌ Adding a new state means touching every consumer; no exhaustiveness help.
The sealed-union pattern fixes all three.
A tag-with-attributes variant
For very simple state machines, you can use a tag alone:
type Order = Pending | Submitted
class Pending: passclass Submitted: submitted_at: datetime…and store the rest of the data in the outer class:
class OrderContext: items: list[Item] state: OrderThis pattern works when most data is shared across states.
Where next
- Sealed Unions — the type-system reference.
- Sealed Unions and Match (tour) — teaching page.
- Errors as Values — sealed unions for
EinResult[T, E].