almessadi.
Back to Index

Result Types for Expected Failures in TypeScript_

Exceptions still have a place, but expected business failures are easier to reason about when they are modeled as data instead of hidden in control flow.

PublishedApril 12, 2024
Reading Time9 min read

TypeScript gives you strong guarantees about many kinds of invalid input.

What it does not do is track thrown exceptions through your program in a useful way.

That is why expected failures often read more cleanly as data than as exceptions.

Not Every Failure Should Throw

There is a difference between:

  • "the payment gateway returned card_declined"
  • "our process hit an invariant that should never happen"

The first is part of normal business flow. The second is exceptional.

If you throw for both, callers have to infer too much from control flow.

A Simple Result Type

You do not need category theory to use the idea well. In TypeScript, a discriminated union is enough:

type Result<T, E> =
  | { ok: true; value: T }
  | { ok: false; error: E };

async function fetchUser(id: string): Promise<Result<User, "NOT_FOUND" | "NETWORK_ERROR">> {
  const response = await fetch(`/api/users/${id}`);

  if (response.status === 404) {
    return { ok: false, error: "NOT_FOUND" };
  }

  if (!response.ok) {
    return { ok: false, error: "NETWORK_ERROR" };
  }

  return { ok: true, value: await response.json() };
}

Now the function signature tells the caller that failure is expected and modeled.

What You Gain

This approach helps when:

  • the failure modes are part of domain logic
  • the caller is expected to react differently to each one
  • you want those cases visible in the type system

The call site becomes explicit:

const result = await fetchUser("123");

if (!result.ok) {
  if (result.error === "NOT_FOUND") return render404();
  return renderRetryState();
}

return renderProfile(result.value);

That is often easier to review than a try/catch that mixes transport errors, parser errors, and business logic failures in one block.

What You Should Still Throw

Result types are not a reason to ban exceptions.

Throw when:

  • the process hit a true invariant violation
  • continuing execution is unsafe
  • a framework boundary expects exceptions

Model as data when:

  • the failure is expected
  • the caller needs to branch on it
  • it is part of normal product behavior

That line is more useful than "exceptions are bad" as a blanket rule.

Further Reading