EduFlow

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.

  1. 1Making failure part of the type
  2. 2Errors with structure
  3. 3Composing steps
  4. 4Wrapping code that throws
  5. 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:

TypeScript
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:

TypeScript
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:

TypeScript
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); // fine

Errors 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:

TypeScript
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:

TypeScript
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.

Railway diagram. Three steps, validateCart, applyCoupon and chargeCard, sit on a success track leading to ok Order. From each step a branch drops to a failure track that ends in a Result with ok false and the union of CartError, CouponError and PaymentError. validateCart applyCoupon chargeCard ok(order) { ok: false, error: CartError | CouponError | PaymentError }
Each step either continues on the success track or exits with its error

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:

TypeScript
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 null where the types say there cannot be one, an unhandled union member reaching assertNever) 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 check ok before they can read value.
  • 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 Result for failures the business expects; keep throw for bugs, broken invariants and infrastructure failures you cannot handle locally.

Further reading

Check your understanding

4 questions

Question 1 of 4

placeOrder(cart): Promise<Order> can fail with a declined card. What does its signature tell a caller about that?

Question 2 of 4

Why does the last line fail to compile?

TypeScript
type Result<T, E> = { ok: true; value: T } | { ok: false; error: E };
declare const r: Result<Order, CheckoutError>;
console.log(r.value.id);
Question 3 of 4

Which failure is a good fit for a Result rather than an exception?

Question 4 of 4

validateCart returns Result<Cart, CartError> and checkout returns Result<Order, CartError | PaymentError>. Does this compile?

TypeScript
const cart = validateCart(input);
if (!cart.ok) return cart;

Practice

Code playground

Return failures, don't throw them

Turn Cartwheel’s coupon step into typed, expected failures:

  1. Define Result<T, E> as a discriminated union on ok, with ok(value) and err(error) helpers.
  2. Make findCoupon return Result<Coupon, CouponError>: coupon-unknown when the code does not exist, coupon-expired (with expiredOn) when expiresOn is before today.
  3. Make checkout return Result<PricedCart, CheckoutError>: check stock first, then the coupon (when couponCode is not null), then return the cart with totalCents (subtotal minus the coupon’s percentage, rounded).
  4. Make errorMessage return a message for each error type that includes its data (the code, the expiry date, or the SKU).
type Coupon = { code: string; percentOff: number; expiresOn: string };
type Cart = { lines: { sku: string; quantity: number; unitPriceCents: number }[] };
type PricedCart = Cart & { totalCents: number };

const COUPONS: Coupon[] = [
  { code: 'WELCOME10', percentOff: 10, expiresOn: '2099-12-31' },
  { code: 'SPRING15', percentOff: 15, expiresOn: '2025-05-31' },
];
const STOCK: Record<string, number> = { 'TENT-2P': 4, 'MUG-SET': 0 };

// 1. Define Result, ok and err.
type Result<T, E> = unknown;

type CouponError =
  | { type: 'coupon-unknown'; code: string }
  | { type: 'coupon-expired'; code: string; expiredOn: string };
type StockError = { type: 'out-of-stock'; sku: string; available: number };
type CheckoutError = CouponError | StockError;

// 2. Return a Result instead of throwing.
function findCoupon(code: string, today: string): Coupon {
  const coupon = COUPONS.find((c) => c.code === code);
  if (!coupon) throw new Error(`Unknown coupon ${code}`);
  if (coupon.expiresOn < today) throw new Error(`${code} expired on ${coupon.expiresOn}`);
  return coupon;
}

function checkStock(cart: Cart): Result<Cart, StockError> {
  for (const line of cart.lines) {
    const available = STOCK[line.sku] ?? 0;
    if (available < line.quantity) return { ok: false, error: { type: 'out-of-stock', sku: line.sku, available } };
  }
  return { ok: true, value: cart };
}

// 3. Compose: stock first, then the coupon (if any), then price the cart.
function checkout(cart: Cart, couponCode: string | null, today: string) {
  const subtotal = cart.lines.reduce((sum, l) => sum + l.quantity * l.unitPriceCents, 0);
  const coupon = couponCode ? findCoupon(couponCode, today) : null;
  const totalCents = coupon ? Math.round(subtotal * (1 - coupon.percentOff / 100)) : subtotal;
  return { ok: true, value: { ...cart, totalCents } };
}

// 4. One message per error type.
function errorMessage(e: CheckoutError): string {
  return 'Something went wrong';
}

// ---- Type tests (do not edit) ----
type T1 = Expect<Equal<ReturnType<typeof findCoupon>, Result<Coupon, CouponError>>>;
type T2 = Expect<Equal<ReturnType<typeof checkout>, Result<PricedCart, CheckoutError>>>;

function typeTests(r: Result<Coupon, CouponError>) {
  // @ts-expect-error: value is only available after checking ok
  r.value.code;
  if (r.ok) r.value.code;
}

const cart: Cart = { lines: [{ sku: 'TENT-2P', quantity: 1, unitPriceCents: 18900 }] };
const outcome = checkout(cart, 'SPRING15', '2026-10-05');
console.log(outcome.ok ? outcome.value.totalCents : errorMessage(outcome.error));
Loading the editor…

Ctrl/⌘ + Enter to run · Runs in a sandbox in your browser. Nothing leaves your device.

Console

(no output)

Checks

  • The file type-checks with zero errors, including all type tests
  • A valid coupon reduces the total (10% off 10000 is 9000)
  • An expired coupon returns a coupon-expired failure with its expiry date
  • Stock is checked before the coupon
  • errorMessage includes each failure’s data