Part 21 – Typestate
This is a series of posts I’m writing about using types as another tool in software development, Continuous Delivery, & keeping LLM’s honest. They’re also a design & refactoring tool, a communication tool, and reduce how many tests you have to write.
Parts
- Part 1 – Branded Types
- Part 2 – Product Types
- Part 3 – Union & Discriminated Unions
- Part 4 – Non-Empty Collections
- Part 5 – Indexed Types
- Part 6 – unknown vs any
- Part 7 – Result
- Part 8 – Schema
- Part 9 – Total Function
- Part 10 – Errors as Values
- Part 11 – Property Tests
- Part 12 – Type Proofs
- Part 13 – Exhaustiveness Checking
- Part 14 – Parse, Don’t Validate
- Part 15 – Anti-Corruption Layer
- Part 16 – Opaque Types
- Part 17 – Maybe
- Part 18 – Smart Constructors
- Part 19 – Pipeline
- Part 20 – Railway Oriented Programming
- Part 21 – Typestate
- Part 22 – Capabilities
- Part 23 – Immutability
- Part 24 – Making Impossible States Impossible
- Part 25 – Type Driven Development: How to do it
- Part 26 – Final Thoughts


We’ve shown how you can compose total functions together using pipeline & Railway Oriented Programming, while still having the order be imperative & easy to read. The downside is you are doing that order manually with just basic type support, e.g. “which .andThen goes in what order”. Sometimes in your domain, if things happen in the wrong order, bad things happen.
submitForm( userData )
.andThen( processOrder )
.andThen( ship )
.andThen( sendEmail )
A bunch wrong here, let’s cover ship first. Orders go through a lot of steps to get them to a shipping state:
type Order = ‘draft’ | ‘submited’ | ‘paid’ | ‘ship’ | ‘shipped’
The bad thing is it accepts an Order in any state:
type Ship = (order: Order) => Result<void, Error>
So either ship just handles all of that internally, or it’s not that smart and just assumes to ship an Order in any state. Either painful business logic in code we have to test mixed in with side-effects, or bugged code.
The fix, be explicit on the transitions; what state is allowed to move to the next state? You do this with Typestate: defining functions that take moar specific types in and returning specific types. Here, we’ll redefine the Order states as a Discriminated Union & functions for enforced transitions:
type Draft = { tag: ‘draft’, items: Item[] }
type Submittted = { tag: ‘submitted’, items: NonEmptyArray<Item> }
type Paid = { tag: ‘paid’; id: PaymentID }
type Submit = (order: Draft) => Result<Submitted, string>
type Pay = (o: Submitted) => Result<Paid, string>
type Ship = (o: Paid) => Result<Shipment, string>
I always think of wiring up these transition functions as “setting up the Roomba”:
submit( userData ) // Submitted comes out, goes into pay
.andThen( pay ) // Paid comes out, goes into ship
.andThen( ship ) // Shipment comes out, goes into email
.andThen( sendEmail )
The same could be done for the UI; instead of submit taking some form that _maybe_ the UI validated, instead, enforce submit to only take a validated form, that way you can’t submit data that wasn’t validated:
type Form = NotValidated | ValidForm
type Submit = (form: ValidForm) => Result<…, …>
Once you build these Typestate types and transition functions, it helps ensure you can’t assemble them in the wrong order; the compiler will help you put them in the right order. This includes those larger functions you’ve created where you may not be aware of what’s inside because of their abstraction, but that’s ok; they’re Total Functions, so all you need is the top-level type signature, the compiler will do the rest… (well, and you attempting to interpret TypeScript’s sometimes obtuse compiler errors).
Leave a Reply