Section 4 · Patterns for Real Codebases
Result Types for Expected Failures
18 min3 min readCode playground
Lesson at a glance
Return expected failures as typed values with a Result union, so callers must handle a declined card or an expired coupon, and keep exceptions for bugs and the truly unexpected.
- 1Making failure part of the type
- 2Errors with structure
- 3Composing steps
- 4Wrapping code that throws
- 5Where exceptions still belong
No video here — just the real thing: a written lesson, runnable examples, a quiz and practice.
Here is the signature of the function at the heart of Cartwheel’s checkout:
async function placeOrder(cart: Cart, payment: PaymentMethod): Promise<Order> { /* … */ }It can fail in at least five ways the business expects every day: the card is declined, an item is out of stock, the coupon expired, the address is undeliverable, the payment provider asks for 3-D Secure. The signature mentions none of them. They are exceptions, thrown from somewhere inside, and TypeScript has no throws clause. A caller who forgets a try/catch compiles fine, and the customer sees a blank screen.
Making failure part of the type
The fix is to return expected failures instead of throwing them, using a discriminated union you already know how to write:
type Result<T, E> = { ok: true; value: T } | { ok: false; error: E };
const ok = <T>(value: T): { ok: true; value: T } => ({ ok: true, value });
const err = <E>(error: E): { ok: false; error: E } => ({ ok: false, error });ok is the discriminant. A caller cannot read value without first proving the call succeeded, because value only exists on one member:
const result = await placeOrder(cart, payment);
showConfirmation(result.value);
// Error: Property 'value' does not exist on type 'Result<Order, CheckoutError>'.
// Property 'value' does not exist on type '{ ok: false; error: CheckoutError; }'.
if (result.ok) showConfirmation(result.value); // fineErrors with structure
E could be a string, but a string is the error equivalent of status: string. Model failures as a discriminated union, each with the data needed to handle it:
type CheckoutError =
| { type: 'card-declined'; declineCode: string }
| { type: 'out-of-stock'; sku: string; available: number }
| { type: 'coupon-expired'; code: string; expiredOn: string };
function errorMessage(e: CheckoutError): string {
switch (e.type) {
case 'card-declined': return `Your card was declined (${e.declineCode}).`;
case 'out-of-stock': return `Only ${e.available} of ${e.sku} left.`;
case 'coupon-expired': return `${e.code} expired on ${e.expiredOn}.`;
}
}The : string return type makes this exhaustive, as you saw in section 1. When the payments team adds 'requires-3ds' to the union, every message function and every UI branch that switches on type is flagged.
Composing steps
Checkout is a sequence: validate the cart, apply the coupon, charge the card. Each step can fail with its own errors. Early returns compose them without nesting:
type CartError = { type: 'out-of-stock'; sku: string; available: number };
type CouponError = { type: 'coupon-expired'; code: string; expiredOn: string };
async function checkout(input: CheckoutInput): Promise<Result<Order, CartError | CouponError | PaymentError>> {
const cart = validateCart(input.lines);
if (!cart.ok) return cart;
const priced = applyCoupon(cart.value, input.coupon);
if (!priced.ok) return priced;
const charge = await chargeCard(priced.value.total, input.payment);
if (!charge.ok) return charge;
return ok(createOrder(priced.value, charge.value));
}After if (!cart.ok), cart is narrowed to { ok: false; error: CartError }, which is assignable to the function’s wider failure type, so it can be returned as is. The success path reads top to bottom, and the return type documents every failure the caller must handle. If someone later adds a fourth step with a new error type and forgets to widen the return type, the return of that step’s failure is a compile error, so the list of failures cannot silently fall out of date.
You will see libraries that offer map and andThen methods to chain results instead. They are fine, but in TypeScript the early-return style is usually easier to read and needs no library: narrowing does the work.
Wrapping code that throws
Most of the failures in a checkout start life as exceptions in someone else’s code: the payment SDK throws a CardError, fetch rejects when the network drops. Convert them once, at the edge, into the Result your own code speaks:
type PaymentError = { type: 'card-declined'; declineCode: string };
async function chargeCard(amount: Money, payment: PaymentMethod): Promise<Result<Receipt, PaymentError>> {
try {
return ok(await paymentSdk.charge(amount, payment));
} catch (e: unknown) {
if (e instanceof CardError) return err({ type: 'card-declined', declineCode: e.code });
throw e; // not a failure we understand: let it propagate
}
}Three things are worth copying. The catch variable is unknown, as it always is under strict, so you narrow it with instanceof before reading code. Only the one failure the business expects becomes a value; everything else is rethrown untouched, stack trace and all. And the function’s return type is now an honest list of what can go wrong, which every caller sees in a tooltip.
Asynchronous results nest the obvious way, Promise<Result<T, E>>. A rejected promise still means “something unexpected happened”, and a resolved { ok: false } means “the business said no”. Keeping those two channels separate is the whole point.
Where exceptions still belong
Result is for failures the business expects and the caller can act on. It is not a replacement for every throw:
- Bugs and broken invariants (a
nullwhere the types say there cannot be one, an unhandled union member reachingassertNever) should throw. There is nothing sensible for the caller to do, and you want a stack trace. - Infrastructure failures you cannot handle locally (the database is down) can propagate to one error boundary or retry layer.
- Third-party code that throws gets wrapped once at the boundary, converting the exceptions you expect into
err(…)and rethrowing the rest.
In the exercise you will build Cartwheel’s coupon step with Result, compose it into a checkout function, and write the exhaustive error messages. Next lesson deals with the data those steps start from: untrusted input.
Key takeaways
- A function’s signature says nothing about what it throws, so expected failures hidden in exceptions are easy for callers to forget.
type Result<T, E> = { ok: true; value: T } | { ok: false; error: E }is a discriminated union, so callers must checkokbefore they can readvalue.- Model errors as a discriminated union too, so each failure carries its own data and the UI can handle every case exhaustively.
- Compose results with early returns (
if (!r.ok) return r;); the narrowed failure is assignable to the caller’s wider error union. - Use
Resultfor failures the business expects; keepthrowfor bugs, broken invariants and infrastructure failures you cannot handle locally.