At Bronscode B.V. we build web applications every day that run in environments where reliability and correctness are of great importance. In that context, we strongly believe in the power of types.
That conviction doesn't come out of nowhere. I studied mathematics, computer science and philosophy and published a paper on type theory. This formal foundation shows up in all the software we build and forms the basis of our framework Eidos.
Every now and then you come across a case where our way of working with types really shines. In this article we show how smart types can check the correctness of status transitions using TypeScript's type system.
Context
For a project, it must be possible to post messages. Messages are published automatically after 30 seconds. It is also possible to create a draft that is not yet published, or to retract a published message.
The statuses are draft, pending, published and retracted. Not every transition between these statuses is allowed. For example, a published message may only be retracted, and a retracted message cannot be edited again.
Union types and tuples
We want to represent the transition from an old to a new status as a tuple. A first attempt is:
type MessageStatus = "draft" | "pending" | "published" | "retracted";
type Transition = [MessageStatus, MessageStatus];
This type is not specific enough. Both tuple positions may contain any status; combinations we do not want to allow are therefore valid as well. Union types do not automatically distribute over tuple types.
With a distributive helper we explicitly create a union of tuples:
type CartesianProduct<A extends string, B extends string> = A extends any
? B extends any
? [A, B]
: never
: never;
type Transition = CartesianProduct<MessageStatus, MessageStatus>;
Then we restrict the valid transitions to the rules of our application:
type AllowedTransitions =
| ["draft", "draft"]
| ["draft", "pending"]
| ["pending", "draft"]
| ["pending", "pending"]
| ["published", "retracted"];
Runtime validation as type narrowing
Runtime validation remains important. In this example, the checks narrow the type at the same time: after the checks, TypeScript knows that only the allowed cases remain.
const transition: Transition = [oldStatus, newStatus];
if (transition[1] === "published") {
throw new Error("Cannot set status to published when updating a message.");
}
if (transition[0] === "retracted") {
throw new Error("Retracted messages cannot be updated.");
}
if (transition[0] === "published" && transition[1] !== "retracted") {
throw new Error("Published messages can only be retracted.");
}
if (transition[0] !== "published" && transition[1] === "retracted") {
throw new Error("Only published messages can be retracted.");
}
transition satisfies AllowedTransitions;
The result is code that is correct, because impossible transitions are ruled out; maintainable, because the rules are explicit and visible locally; and statically checkable, because a type error appears when a new transition is added without updating the checks.
The full code is available in the TypeScript Playground.