02 Typed failure modes 6 chapters

Theme 04 · Debuggability-Focused

Typed failure modes

Explanation

Typed failure modes

Plain Human Explanation

Some failures are not surprises. A card can be declined, a provider can time out, and a duplicate webhook can arrive twice. Typed failure modes make those expected outcomes visible in the code.

That visibility matters because each failure asks for a different product response. A customer-fixable problem might need a message. A temporary provider problem might need a retry. A duplicate event might need no action at all.

Technical Explanation

Model known failures as a union of named cases. Each case should carry the fields needed for its response: whether it is retryable, what message is safe to show, or which idempotency key was already processed.

This is different from structured errors in emphasis. Structured errors help you log and group failures. Typed failure modes help the caller decide what to do next.

Why It Matters

  • User impact: customers get the right response for the problem, instead of a generic failure.
  • Product behavior: retry, ignore, notify, and ask-the-user flows are handled deliberately.
  • Risk: callers may retry permanent failures, hide user-fixable failures, or alert on harmless duplicates.
  • Decision point: type a failure when product behavior changes based on which failure happened.

The Core Move

Make expected failures part of the function contract. If the caller must react differently, the return type should force that decision into the open.

Small Example

Typed failure modes: Small Example

Bad TypeScript Example

export async function chargeCard(cardId: string, amountCents: number): Promise<boolean> {
  try {
    await fetch("/payments/charge", {
      method: "POST",
      body: JSON.stringify({ cardId, amountCents }),
    });
    return true;
  } catch {
    return false;
  }
}
export async function chargeCard(
  cardId: string,
  amountCents: number,
): Promise<boolean> {
  try {
    await fetch("/payments/charge", {
      method: "POST",
      body: JSON.stringify({
        cardId,
        amountCents,
      }),
    });

    return true;
  } catch {
    return false;
  }
}

Good TypeScript Example

type ChargeFailure =
  | { kind: "card-declined"; userMessage: string }
  | { kind: "provider-timeout"; retryAfterSeconds: number }
  | { kind: "invalid-amount"; reason: string };

type ChargeResult =
  | { ok: true; receiptId: string }
  | { ok: false; failure: ChargeFailure };

export async function chargeCard(cardId: string, amountCents: number): Promise<ChargeResult> {
  if (amountCents <= 0) {
    return { ok: false, failure: { kind: "invalid-amount", reason: "Amount must be positive." } };
  }

  const response = await fetch("/payments/charge", {
    method: "POST",
    body: JSON.stringify({ cardId, amountCents }),
  });

  if (response.status === 402) {
    return { ok: false, failure: { kind: "card-declined", userMessage: "Use another card." } };
  }
  if (response.status === 503) {
    return { ok: false, failure: { kind: "provider-timeout", retryAfterSeconds: 30 } };
  }

  return { ok: true, receiptId: await response.text() };
}
type ChargeFailure =
  | {
      kind: "card-declined";
      userMessage: string;
    }
  | {
      kind: "provider-timeout";
      retryAfterSeconds: number;
    }
  | {
      kind: "invalid-amount";
      reason: string;
    };

type ChargeResult =
  | {
      ok: true;
      receiptId: string;
    }
  | {
      ok: false;
      failure: ChargeFailure;
    };

export async function chargeCard(
  cardId: string,
  amountCents: number,
): Promise<ChargeResult> {
  if (amountCents <= 0) {
    return {
      ok: false,
      failure: {
        kind: "invalid-amount",
        reason: "Amount must be positive.",
      },
    };
  }

  const response = await fetch(
    "/payments/charge",
    {
      method: "POST",
      body: JSON.stringify({
        cardId,
        amountCents,
      }),
    },
  );

  if (response.status === 402) {
    return {
      ok: false,
      failure: {
        kind: "card-declined",
        userMessage: "Use another card.",
      },
    };
  }

  if (response.status === 503) {
    return {
      ok: false,
      failure: {
        kind: "provider-timeout",
        retryAfterSeconds: 30,
      },
    };
  }

  return {
    ok: true,
    receiptId: await response.text(),
  };
}

What Changed

  • The bad version reduces every failure to false, so the caller cannot choose the right response.
  • The good version names the known failure modes and gives each one the data needed for its next step.
  • The function contract now tells callers that charging a card can succeed, ask the user to fix something, or retry later.

Realistic Example

Typed failure modes: Realistic Example

This example handles an invoice payment. A declined card, a duplicate request, and a provider outage should not all become the same support mystery.

Bad TypeScript Example

export async function payInvoice(invoiceId: string, provider: any, store: any) {
  const invoice = await store.findInvoice(invoiceId);
  const charge = await provider.charge(invoice.customerId, invoice.amountCents);

  if (!charge.ok) {
    throw new Error("Payment failed");
  }

  await store.markPaid(invoiceId, charge.receiptId);
  return { paid: true };
}
export async function payInvoice(
  invoiceId: string,
  provider: any,
  store: any,
) {
  const invoice =
    await store.findInvoice(invoiceId);

  const charge = await provider.charge(
    invoice.customerId,
    invoice.amountCents,
  );

  if (!charge.ok) {
    throw new Error("Payment failed");
  }

  await store.markPaid(
    invoiceId,
    charge.receiptId,
  );

  return {
    paid: true,
  };
}

Good TypeScript Example

type Invoice = {
  id: string;
  customerId: string;
  amountCents: number;
  status: "open" | "paid";
};

type ChargeSuccess = {
  receiptId: string;
};

type ChargeFailure =
  | { kind: "declined"; userMessage: string }
  | { kind: "temporary-provider-failure"; retryAfterSeconds: number };

type ChargeResult =
  | { ok: true; charge: ChargeSuccess }
  | { ok: false; failure: ChargeFailure };

type PayInvoiceResult =
  | { ok: true; receiptId: string }
  | { ok: false; failure: "already-paid" | "not-found" | ChargeFailure };

type InvoiceStore = {
  findInvoice(invoiceId: string): Promise<Invoice | null>;
  markPaid(invoiceId: string, receiptId: string): Promise<void>;
};

type PaymentProvider = {
  charge(customerId: string, amountCents: number): Promise<ChargeResult>;
};

export async function payInvoice(
  invoiceId: string,
  provider: PaymentProvider,
  store: InvoiceStore,
): Promise<PayInvoiceResult> {
  const invoice = await store.findInvoice(invoiceId);
  if (!invoice) return { ok: false, failure: "not-found" };
  if (invoice.status === "paid") return { ok: false, failure: "already-paid" };

  const charge = await provider.charge(invoice.customerId, invoice.amountCents);
  if (!charge.ok) return { ok: false, failure: charge.failure };

  await store.markPaid(invoice.id, charge.charge.receiptId);
  return { ok: true, receiptId: charge.charge.receiptId };
}
type Invoice = {
  id: string;
  customerId: string;
  amountCents: number;
  status: "open" | "paid";
};

type ChargeSuccess = {
  receiptId: string;
};

type ChargeFailure =
  | {
      kind: "declined";
      userMessage: string;
    }
  | {
      kind: "temporary-provider-failure";
      retryAfterSeconds: number;
    };

type ChargeResult =
  | {
      ok: true;
      charge: ChargeSuccess;
    }
  | {
      ok: false;
      failure: ChargeFailure;
    };

type PayInvoiceResult =
  | {
      ok: true;
      receiptId: string;
    }
  | {
      ok: false;
      failure:
        | "already-paid"
        | "not-found"
        | ChargeFailure;
    };

type InvoiceStore = {
  findInvoice(
    invoiceId: string,
  ): Promise<Invoice | null>;
  markPaid(
    invoiceId: string,
    receiptId: string,
  ): Promise<void>;
};

type PaymentProvider = {
  charge(
    customerId: string,
    amountCents: number,
  ): Promise<ChargeResult>;
};

export async function payInvoice(
  invoiceId: string,
  provider: PaymentProvider,
  store: InvoiceStore,
): Promise<PayInvoiceResult> {
  const invoice =
    await store.findInvoice(invoiceId);

  if (!invoice)
    return {
      ok: false,
      failure: "not-found",
    };

  if (invoice.status === "paid")
    return {
      ok: false,
      failure: "already-paid",
    };

  const charge = await provider.charge(
    invoice.customerId,
    invoice.amountCents,
  );

  if (!charge.ok)
    return {
      ok: false,
      failure: charge.failure,
    };

  await store.markPaid(
    invoice.id,
    charge.charge.receiptId,
  );

  return {
    ok: true,
    receiptId: charge.charge.receiptId,
  };
}

What Changed

  • The bad version turns every payment problem into an exception with no product meaning.
  • The good version distinguishes final product states from provider failures.
  • The caller can now decide whether to show a message, skip duplicate work, or schedule a retry.

System Example

Typed failure modes: System Example

At system scale, typed failure modes keep retries and customer communication honest. The queue worker should retry temporary failures, ignore harmless duplicates, and escalate real payment problems.

Larger System-Level Bad TypeScript Example

export async function processPaymentJob(job: any, services: any) {
  try {
    await services.payments.payInvoice(job.invoiceId);
    await services.queue.complete(job.id);
  } catch (error) {
    services.logger.error("payment job failed", { job, error });
    await services.queue.retry(job.id);
  }
}
export async function processPaymentJob(
  job: any,
  services: any,
) {
  try {
    await services.payments.payInvoice(
      job.invoiceId,
    );

    await services.queue.complete(job.id);
  } catch (error) {
    services.logger.error(
      "payment job failed",
      {
        job,
        error,
      },
    );

    await services.queue.retry(job.id);
  }
}

Larger System-Level Good TypeScript Example

type PaymentFailure =
  | { kind: "declined"; userMessage: string }
  | { kind: "temporary-provider-failure"; retryAfterSeconds: number };

type PayInvoiceResult =
  | { ok: true; receiptId: string }
  | { ok: false; failure: "already-paid" | "not-found" | PaymentFailure };

type PaymentService = {
  payInvoice(invoiceId: string): Promise<PayInvoiceResult>;
};

type Queue = {
  complete(jobId: string): Promise<void>;
  retry(jobId: string, delaySeconds: number): Promise<void>;
  fail(jobId: string, reason: string): Promise<void>;
};

type Logger = {
  info(event: string, fields: Record<string, unknown>): void;
  warn(event: string, fields: Record<string, unknown>): void;
};

type PaymentJob = {
  id: string;
  invoiceId: string;
};

export async function processPaymentJob(
  job: PaymentJob,
  payments: PaymentService,
  queue: Queue,
  logger: Logger,
) {
  const result = await payments.payInvoice(job.invoiceId);

  if (result.ok || result.failure === "already-paid") {
    await queue.complete(job.id);
    logger.info("payment_job.completed", { jobId: job.id, invoiceId: job.invoiceId });
    return;
  }

  if (result.failure === "not-found") {
    await queue.fail(job.id, "invoice-not-found");
    logger.warn("payment_job.failed", { jobId: job.id, invoiceId: job.invoiceId, reason: "not-found" });
    return;
  }

  if (result.failure.kind === "temporary-provider-failure") {
    await queue.retry(job.id, result.failure.retryAfterSeconds);
    logger.warn("payment_job.retrying", { jobId: job.id, invoiceId: job.invoiceId });
    return;
  }

  await queue.fail(job.id, "card-declined");
  logger.warn("payment_job.failed", { jobId: job.id, invoiceId: job.invoiceId, reason: "declined" });
}
type PaymentFailure =
  | {
      kind: "declined";
      userMessage: string;
    }
  | {
      kind: "temporary-provider-failure";
      retryAfterSeconds: number;
    };

type PayInvoiceResult =
  | {
      ok: true;
      receiptId: string;
    }
  | {
      ok: false;
      failure:
        | "already-paid"
        | "not-found"
        | PaymentFailure;
    };

type PaymentService = {
  payInvoice(
    invoiceId: string,
  ): Promise<PayInvoiceResult>;
};

type Queue = {
  complete(jobId: string): Promise<void>;
  retry(
    jobId: string,
    delaySeconds: number,
  ): Promise<void>;
  fail(
    jobId: string,
    reason: string,
  ): Promise<void>;
};

type Logger = {
  info(
    event: string,
    fields: Record<string, unknown>,
  ): void;
  warn(
    event: string,
    fields: Record<string, unknown>,
  ): void;
};

type PaymentJob = {
  id: string;
  invoiceId: string;
};

export async function processPaymentJob(
  job: PaymentJob,
  payments: PaymentService,
  queue: Queue,
  logger: Logger,
) {
  const result = await payments.payInvoice(
    job.invoiceId,
  );

  if (
    result.ok ||
    result.failure === "already-paid"
  ) {
    await queue.complete(job.id);

    logger.info("payment_job.completed", {
      jobId: job.id,
      invoiceId: job.invoiceId,
    });

    return;
  }

  if (result.failure === "not-found") {
    await queue.fail(
      job.id,
      "invoice-not-found",
    );

    logger.warn("payment_job.failed", {
      jobId: job.id,
      invoiceId: job.invoiceId,
      reason: "not-found",
    });

    return;
  }

  if (
    result.failure.kind ===
    "temporary-provider-failure"
  ) {
    await queue.retry(
      job.id,
      result.failure.retryAfterSeconds,
    );

    logger.warn("payment_job.retrying", {
      jobId: job.id,
      invoiceId: job.invoiceId,
    });

    return;
  }

  await queue.fail(job.id, "card-declined");

  logger.warn("payment_job.failed", {
    jobId: job.id,
    invoiceId: job.invoiceId,
    reason: "declined",
  });
}

What Changed

  • The bad worker retries every thrown error, including failures that will never succeed.
  • The good worker handles each known outcome with a matching queue action.
  • The typed result makes it hard to add a new failure mode without deciding how the system should treat it.

When To Use It

Typed failure modes: When To Use It

Use This When

  • Expected failures change the next action: retry, fail permanently, show a user message, or ignore as duplicate.
  • A caller currently inspects strings, status codes, booleans, or thrown errors to infer what happened.
  • A queue, webhook, payment, import, or sync workflow needs different handling for normal failure cases.

Avoid This When

  • No caller can recover from or distinguish the failure.
  • The failure is a programmer bug that should crash loudly during development.
  • The union would copy provider-specific details that should stay behind a smaller domain decision.

Tradeoffs

Typed failure modes add branches, and every branch needs a clear owner. The benefit is that retry and support behavior becomes reviewable. If the branches all do the same thing, keep the type simpler.

  • Structured errors
  • Safe telemetry
  • Tracing

Practice Prompt

Typed failure modes: Practice Prompt

Beginner Exercise

Find a function that returns true, false, or null for an operation that can fail in more than one expected way. Replace the return value with a named result union.

Intermediate Exercise

Pick a retrying job or webhook handler. Model at least one retryable failure, one permanent failure, and one no-op success. Make the handler choose different actions for them.

Stretch Exercise

Add an exhaustive check for a failure union so a future new failure cannot compile until the caller decides how to handle it.

Reflection Question

Which failures are normal product outcomes, and which ones should still be treated as unexpected bugs?

Suggest an edit

Leave a private editorial note. This creates a GitHub issue for this curriculum page.