Careeroza
State Machines with Discriminated Unions
Only Valid Transitions Compile
An order can go PLACED -> PAID -> SHIPPED -> DELIVERED, or be CANCELLED before shipping. Encoding the allowed transitions in a type makes an invalid move (DELIVERED -> PLACED) a compile error, and each state carries only the data valid for it.
Example
type OrderState =
| { status: "PLACED"; id: string; amount: number }
| { status: "PAID"; id: string; amount: number; paymentId: string }
| { status: "SHIPPED"; id: string; amount: number; paymentId: string; awb: string }
| { status: "DELIVERED"; id: string; deliveredAt: string }
| { status: "CANCELLED"; id: string; reason: string };
type Of<S extends OrderState["status"]> = Extract<OrderState, { status: S }>;
const pay = (o: Of<"PLACED">, paymentId: string): Of<"PAID"> => ({ ...o, status: "PAID", paymentId });
const ship = (o: Of<"PAID">, awb: string): Of<"SHIPPED"> => ({ ...o, status: "SHIPPED", awb });
const deliver = (o: Of<"SHIPPED">, at: string): Of<"DELIVERED"> => ({ status: "DELIVERED", id: o.id, deliveredAt: at });
const cancel = (o: Of<"PLACED" | "PAID">, reason: string): Of<"CANCELLED"> => ({ status: "CANCELLED", id: o.id, reason });
function describe(o: OrderState): string {
switch (o.status) {
case "PLACED": return `${o.id}: waiting for payment of Rs. ${o.amount}`;
case "PAID": return `${o.id}: paid (${o.paymentId})`;
case "SHIPPED": return `${o.id}: shipped, AWB ${o.awb}`;
case "DELIVERED": return `${o.id}: delivered at ${o.deliveredAt}`;
case "CANCELLED": return `${o.id}: cancelled (${o.reason})`;
}
}
const placed: Of<"PLACED"> = { status: "PLACED", id: "OD-1", amount: 1299 };
const paid = pay(placed, "pay_123");
const shipped = ship(paid, "AWB998877");
const delivered = deliver(shipped, "2026-01-18T14:05:00Z");
[placed, paid, shipped, delivered, cancel(pay({ ...placed, id: "OD-2" }, "pay_9"), "changed mind")]
.forEach((s) => console.log(describe(s)));Output
OD-1: waiting for payment of Rs. 1299 OD-1: paid (pay_123) OD-1: shipped, AWB AWB998877 OD-1: delivered at 2026-01-18T14:05:00Z OD-2: cancelled (changed mind)
Invalid Transition
Example
Shipping an order that was never paid, or cancelling a shipped one, does not compile (TS2345).
type OrderState =
| { status: "PLACED"; id: string }
| { status: "PAID"; id: string; paymentId: string }
| { status: "SHIPPED"; id: string; paymentId: string; awb: string };
type Of<S extends OrderState["status"]> = Extract<OrderState, { status: S }>;
declare function ship(o: Of<"PAID">, awb: string): Of<"SHIPPED">;
declare function cancel(o: Of<"PLACED" | "PAID">): void;
const placed: Of<"PLACED"> = { status: "PLACED", id: "OD-1" };
const shipped = ship(placed, "AWB1");
cancel({ status: "SHIPPED", id: "OD-1", paymentId: "p", awb: "a" });Compiler output (tsc)
Line 11: error TS2345: Argument of type '{ status: "PLACED"; id: string; }' is not assignable to parameter of type '{ status: "PAID"; id: string; paymentId: string; }'.
Property 'paymentId' is missing in type '{ status: "PLACED"; id: string; }' but required in type '{ status: "PAID"; id: string; paymentId: string; }'.
Line 12: error TS2322: Type '"SHIPPED"' is not assignable to type '"PLACED" | "PAID"'.