Production
A boolean can't hold a payment
Checkout is where the difference between a type that compiles and a type that is true stops being academic. When your types are wrong about a draft post, you get a bug report. When your types are wrong about money, you get a customer whose card was charged twice and a support thread you will read on a Saturday.
This post takes the counting discipline from making impossible states unrepresentable and applies it to the most over-simplified field in commercial software.
The naive model
An order is paid or it isn't. Every instinct says:
type Payment = { isPaid: boolean } Now go read your payment provider's own state diagram for a day. A payment can be pending — submitted, and the provider has not answered yet, so you genuinely do not know. It can be authorized — the funds are held but not taken, which is not the same as unpaid, because the hold expires and the money was never yours. It can be captured — actually settled. It can be failed. And it can be refunded, which is not "unpaid" either: money moved twice and both movements are facts the finance team will ask about.
Five states. A boolean holds two. The other three don't disappear when you ignore them — they come back as isPaid: false on an authorized payment, rendered as a helpful "Payment failed — try again" button, which authorizes a second hold on the same card. So the honest type is the boring one:
// Core/App/Order/PaymentStatus.ts — the whole file
import * as JD from "decoders"
export type PaymentStatus =
| "PENDING"
| "AUTHORIZED"
| "CAPTURED"
| "FAILED"
| "REFUNDED"
export const paymentStatusDecoder: JD.Decoder<PaymentStatus> =
JD.oneOf(["PENDING", "AUTHORIZED", "CAPTURED", "FAILED", "REFUNDED"]) Eleven lines, including the decoder that validates what the provider's webhook actually sent. Every switch over a PaymentStatus is now forced by the compiler to say what the UI does when the payment is AUTHORIZED — which is exactly the case a boolean-based UI silently gets wrong.
Encode the domain's asymmetries
Here's the part that pays for itself twice. If you sell both physical goods and downloads, some orders ship and some never will — that's not an edge case, that's the catalogue. The order type can say so:
// Core/App/Order.ts
export type Order = {
orderID: OrderID
customerID: CustomerID
lines: Array<OrderLine>
payment: PaymentStatus // every order has one
shipping: Maybe<ShippingStatus> // downloads never ship
// …totals, addresses, discounts
} shipping is Maybe<ShippingStatus>; payment is not optional. That single line of asymmetry is a business fact, checked by the compiler. Nobody can write a "mark everything delivered" batch job that crashes on the download-only orders — the type made them ask the question at design time.
States within states: what is this order doing right now?
The order page's most important visual is a single badge: is this order fine, waiting on the customer, stuck, or done? That's not one enum — it's a small state machine, and the nested union carries data only where it exists:
// Core/App/Order/OrderPhase.ts
export type OrderPhase =
| { _t: "DRAFT" }
| { _t: "CANCELLED"; reason: CancelReason }
| { _t: "AWAITING_PAYMENT"; lastFailure: Maybe<PaymentFailure> }
| { _t: "PLACED"
lastFailure: Maybe<PaymentFailure>
fulfilment: FulfilmentPhase }
export type FulfilmentPhase =
| { _t: "ON_TRACK"; stage: FulfilmentStage }
| { _t: "ON_HOLD"
issue: Maybe<HoldIssue>
readyToShip: boolean }
| { _t: "COMPLETED" } Read what's impossible now. A hold issue — the banner an agent must resolve — can only exist while an order is actually placed and held; you structurally cannot render "action required" on a cancelled order. The fulfilment stage exists only while things are ON_TRACK, so no view can show a progress bar for an order that is stuck. AWAITING_PAYMENT is a first-class state because a card really does get declined after checkout, and for those minutes the order is neither a draft nor placed.
One pure function derives this phase from the raw order row, with exhaustive switches all the way down. The UI never recomputes or re-guesses; it pattern-matches.
Everything at the edge of the domain is a union
Once you start, you can't stop, because reality keeps agreeing with you:
type SubscriptionStatus = "TRIALING" | "ACTIVE" | "PAST_DUE" | "CANCELLED"
type InviteStatus = "SENT" | "OPENED" | "ACCEPTED" | "EXPIRED" | "REVOKED"
type DeliveryState = "IN_TRANSIT" | "OUT_FOR_DELIVERY" | "ATTEMPTED" | "DELIVERED" | "RETURNED" That last one is worth defending in review. The carrier reports five outcomes and two of them — ATTEMPTED and RETURNED — are the only two support ever gets called about. A delivered: boolean throws both away at the boundary, and no amount of downstream cleverness gets them back. The union preserves the truth the sender actually sent.
What this buys you
- Exhaustiveness as a to-do list. When the provider adds
DISPUTED, you add one variant — and the compiler lists every switch in the app that must now decide what to display. - No defensive rendering. There is no
if (order.holdIssue && order.phase !== "CANCELLED")anywhere, because that contradiction can't be constructed. - Design-time questions.
Maybe<ShippingStatus>forced "what about orders that never ship?" to be answered in a code review, not in an incident review.
The counting discipline is the same one from making impossible states unrepresentable — the only difference is that here, the states were counted by reading the payment provider's state diagram and the support team's inbox instead of a whiteboard. Types are cheap. Model what's actually there.
Maybe, decoders on every boundary — are the core of ts-bedrock. New to them? The product & sum types lesson builds them from scratch.