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.
Related Concepts
- 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.