ErrorsLANGUAGE-SPECIFICPARADIGM-SPECIFICCONTESTED

Result Types

Result<Payment, PaymentError> puts expected failure in the return type, where the compiler can insist somebody deals with it. What that costs depends enormously on the language.

The requirement, the obvious build, and why it breaks

Every lesson starts where the work starts: someone asked for something, and the first implementation that comes to mind survives until the requirement changes.

The question

When is it worth making failure part of the return type rather than a separate control-flow path?

The requirement

A charge that fails silently has cost the company money twice. The postmortem asks for a mechanism, not a rule: make it impossible to call a payment function and forget that it can fail.

The obvious build

Return the payment, throw on failure. Callers who care write try, callers who do not get an exception that propagates to the top handler — which is safe, because nothing is lost.

Why it breaks

The safety is real only if propagation is what you wanted. The silent-loss bug was await charge(o) inside a .map() with the promise unawaited, and no exception ever reached anyone — a mechanism that depends on the caller doing the right thing does not survive the caller doing the wrong thing.

How it breaks as requirements change
  • The safety is real only if propagation is what you wanted. The silent-loss bug was await charge(o) inside a .map() with the promise unawaited, and no exception ever reached anyone — a mechanism that depends on the caller doing the right thing does not survive the caller doing the wrong thing.
  • As requirements grow, "which of the things this function can throw are expected?" is answerable only by reading the whole call tree, and the answer changes every time somebody adds a dependency two levels down.
  • The moment a second caller wants a different response to declines — the retry job wants to record and continue, the checkout wants to show a message — the exception has to be caught, inspected and re-thrown, and the type system helps with none of it.
  • Refactoring makes it worse rather than better: extracting a helper moves a throw further from its handler, and nothing in the tooling notices that a previously-handled failure is now unhandled.
RequirementConstraintsInvariantsResponsibilitiesBoundariesInterfacesStateDependenciesFailureImplementationTestsFeedbackEvolution

What limits the solution, and what must never stop being true

This domain leads with these two. A design that ignores its constraints is not a design, and an invariant nobody named is one nothing is protecting.

Constraints
  • The codebase is TypeScript; there are no sum types with exhaustive matching in the language, only discriminated unions plus a never trick that engineers must remember to write.
  • The async story matters: every payment call is a promise, and a Result inside a Promise is two layers a reader has to unwrap.
  • The team has three people who have written Rust or Scala and five who have not, so the idiom cannot assume familiarity (Knowledge Sharing).
Invariants
  • A failure a caller has not explicitly handled must not be able to reach production as a silent success.
  • The distinction between "the payment was declined" and "the code crashed" survives every layer between the domain and the caller.
  • Nothing in the result path can be discarded by a single ignored return value without the tooling saying so.

Who owns what, and where the seams fall

Responsibilities decide boundaries; boundaries decide what an interface has to say.

Responsibilities
  • The function that can fail owns declaring which failures are expected, in its signature.
  • The caller owns choosing a response for each of them, and the compiler owns noticing when it did not.
  • The unwrapping boundary — usually the edge — owns turning a Result into whatever the transport needs (Error Boundaries).
  • Nothing owns programming bugs. They stay as exceptions, deliberately outside the Result, so the two categories cannot be confused (Error Modeling).
Boundaries
  • The Result boundary should coincide with the domain boundary: inside a module, plain values and exceptions for bugs; crossing out of it, a Result that names the expected failures.
  • Wrapping everything in Result all the way down produces a codebase where every function is three lines of unwrapping — the boundary is the point, and applying it everywhere removes the signal (Over-Decomposition).
  • Async is a second boundary that has to be crossed at the same place, or you get Promise<Result<T, E>> nested inside Result<Promise<T>, E> and nobody can read either.

The same function in a language that enforces it

It is worth seeing the version where the compiler does the work, because it makes visible what the TypeScript version is approximating with tooling. Nothing here is a library feature: the enforcement is the type system refusing to compile a discarded result and refusing an incomplete match.

The second half is the point. ? propagates the failure without the caller restating it, which is the answer to the main complaint about Result — the intermediate frames do not have to enumerate anything.

Expected failure in the signature
1enum PaymentError {
2 Declined { code: String },
3 Unavailable { waited_ms: u64 },
4}
5
6fn charge(order: &Order) -> Result<Payment, PaymentError> { /* ... */ }
7
8fn checkout(order: &Order) -> Result<Receipt, PaymentError> {
9 let payment = charge(order)?; // propagates, no restating
10 Ok(Receipt::for_payment(payment))
11}
12
13fn present(order: &Order) -> String {
14 match charge(order) {
15 Ok(p) => format!("charged {}", p.id),
16 Err(PaymentError::Declined { code }) => format!("declined: {code}"),
17 Err(PaymentError::Unavailable { .. }) => "try again shortly".into(),
18 // adding a third variant makes THIS a compile error
19 }
20}

A panic from a bug is not a PaymentError and cannot be matched here. That separation is enforced by the type, not by convention — which is exactly the property a TypeScript version has to buy with a lint rule and a code review habit.

What it looks like where the compiler will not help

The TypeScript version is not a worse idea, but it is a weaker one, and pretending otherwise is how teams end up with the ceremony and none of the guarantee. Two things have to be added by hand: exhaustiveness, and the impossibility of ignoring the result.

The assertNever line is doing real work. Without it, adding a third case to PaymentError compiles cleanly and produces a function that silently returns undefined for the new case — strictly worse than the exception it replaced.

Discriminated union, with and without enforcement
Result-shaped, guarantee-free
type Result<T, E> = { ok: true; value: T } | { ok: false; error: E }

function present(r: Result<Payment, PaymentError>) {
  if (r.ok) return `charged ${r.value.id}`
  if (r.error.kind === 'declined') return 'declined'
  // 'unavailable' silently falls through to undefined,
  // and so will every case added later
}

charge(order)   // floating promise, result discarded, no warning
The same shape, with the two props
function present(r: Result<Payment, PaymentError>): string {
  if (r.ok) return `charged ${r.value.id}`
  switch (r.error.kind) {
    case 'declined':    return `declined: ${r.error.code}`
    case 'unavailable': return 'try again shortly'
    default:            return assertNever(r.error)  // compile error on a 3rd case
  }
}

const assertNever = (x: never): never => { throw new Error(`unhandled ${JSON.stringify(x)}`) }
// plus: no-floating-promises + a no-unused-result lint in CI

The type is identical; only the enforcement differs, and the enforcement is the entire value proposition. A team that adopts the shape without the lint rules has taken on the ceremony and kept the original bug, which is the most common way this design fails in practice.

Priced honestly against the alternatives

Neither column wins outright, and the axis that decides it is usually not on the table: how many frames sit between the failure and its handler, and how much you trust convention in a codebase this size.

The scores below are a comparison, not a measurement. What they are useful for is noticing that the two approaches differ most on testability and least on performance — which tells you the argument is about design feedback, not efficiency, and should be conducted on those terms.

Expected failure: three mechanisms
OptionSimplicityFlexibilityTestabilityMigration costOperationalNote
Exceptions everywhereCleanest happy path and no intermediate frame mentions failure. What can be thrown is invisible in the signature, so testing every failure means knowing the call tree, and adding a new one is undetectable at compile time.
Result at domain boundariesFailures are values, so tests construct them directly and callers are forced to decide. Migration is the weak axis: converting a throwing function ripples through every signature up to the handler.
Result everywhereMaximum enforcement, and the reason most teams that try it retreat: unwrapping ceremony in functions that never fail removes the signal that made the boundary version useful.

caveat These scores assume a language where exhaustiveness and non-discarding can be enforced at all. In Python or Java without checked exceptions the testability advantage of Result shrinks to a convention, and the simplicity penalty stays exactly the same — which flips the recommendation for a lot of real codebases. The scores also say nothing about the team: five engineers who have never used sum types will write worse Result code than good exception code, and that is a legitimate input to the decision.

How to build it

Most important first.

  • Put only *expected* failures in E. If a case in E would be a bug when it happens, it does not belong there (An Error Taxonomy That Survives Contact).
  • Make E a closed union so the caller's switch can be exhaustive, and add the never-assignment exhaustiveness check in languages that do not enforce it for you.
  • Make ignoring the result mechanically detectable — @typescript-eslint/no-floating-promises plus a lint rule on unused returns, or #[must_use] in a language that has it. Without that, Result is documentation.
  • Give combinators to the common paths (map, andThen) but resist building a full effect library; the value is in the signature, and the abstraction budget goes fast (What an Abstraction Costs).
  • Unwrap once at the edge and convert to the transport's vocabulary. A Result that reaches the HTTP serializer means the boundary is in the wrong place.
  • Adopt it where failure is genuinely part of the domain — payments, imports, external calls — and leave the rest throwing. A mixed codebase with a clear rule is better than a uniform one nobody believes in (When Design Does Not Pay).

What the next change costs

The field this whole domain exists for. A structure is only better if it makes the change after this one cheaper — and it is worth saying which changes it does not help.

Cost of the next change
  • Adding a new failure case to E costs one compile error per caller, each of which is a real decision someone should make. In a codebase with forty callers that is a genuinely expensive afternoon — and it is forty decisions that would otherwise have been made by default.
  • Changing a caller's *response* to an existing failure is local and free: one branch, one test.
  • What stays expensive: moving a function from throwing to returning Result after the fact. The change ripples up through every signature between it and the nearest handler, which is why the boundary is worth choosing early (The Cost of Change).
  • In a language without exhaustiveness checking, adding a case to E costs nothing at compile time and everything at runtime, which inverts the whole argument — the cost has to be recreated with a lint rule and a test.
What the recommended approach costs
  • Every intermediate function between the failure and its handler has to mention the failure. That is honest and it is also noise in the eighty percent of the call chain that just passes it along.
  • Composition gets harder before it gets easier. Two Result-returning calls and one throwing call in the same function produce three shapes of control flow, and the code reads worse than either pure style.
  • The guarantee is only as strong as the weakest enforcement. In TypeScript it rests on a lint rule, which means it rests on nobody adding an eslint-disable comment at 6pm on a Friday.

What can go wrong

Failure modes
  • Everything gets wrapped, including functions that cannot fail, and the codebase acquires a uniform three-line unwrapping ceremony that hides the two places where failure was actually interesting.
  • E becomes Error or string, at which point the type carries no information and the design has bought nothing but syntax.
  • Callers reach for the escape hatch — .unwrap(), !, as any — under deadline, and the guarantee is gone at exactly the sites that were rushed.
  • The mitigation fails in its own way: the lint rule that makes ignoring a result an error gets disabled in one directory during a migration, and that directory is where the next silent failure lives.
Dependencies, and their direction
  • Callers depend on the specific E of everything they call, which is the intended coupling: the signature is the contract.
  • That coupling is transitive in a way exceptions are not — widening a deep function's E propagates outward through every signature that returns it, which is either honest accounting or a refactoring tax depending on your mood.
  • The Result type itself is a leaf dependency and must stay one; a Result that knows about HTTP has already failed (Dependency Direction).
Misreads
  • "Results are safer than exceptions." Not inherently. A Result that everyone .unwrap()s is less safe than an exception that propagates, because at least the exception was loud. The safety comes from enforcement, and enforcement is a language and tooling property (Exceptions, Where They Help and Where They Hide the Flow).
  • "Put every error in the Result." Bugs in E are the classic mistake: it makes callers write handling code for situations where the only correct response is to crash, and it hides defects behind a branch.
  • "This is just a naming convention for {ok, error}." The object shape is trivial; the design decisions are which failures qualify as expected, where the boundary is, and what makes ignoring one detectable.
  • "We can adopt it gradually everywhere." Gradual adoption *at a boundary* works. Gradual adoption function-by-function produces a codebase where the calling convention is unpredictable, which costs more than either style (Incremental Migration).
Smells this explains
  • swallowed-errors
  • primitive-obsession

Testing it, and how it ages

What to test, and at which boundary
  • Test that each failure case is *reachable and returned*, not merely representable — a case in E no code path can produce is a lie in the signature.
  • Test the caller's branch for each case, which is now trivial because constructing a failure is constructing a value (Testing as Design Feedback).
  • A lint or compile check in CI that no result is discarded. This is the test that actually enforces the invariant; everything else is convention.
  • One test at the edge that each case maps to the intended transport response, with the domain stubbed (What a Unit Is).
How this design ages
  • E grows as the domain's understanding of failure grows, and each addition is a small forced review across the codebase — a feature while the team is eight people, a serious tax at eighty.
  • The common refactor after a year is splitting one wide E into per-operation errors, because most callers were handling three cases and ignoring nine.
  • It stops being right if the language or framework moves — an effect system, checked exceptions, or a framework that swallows returns — and the mechanism no longer buys enforcement (What a Framework Charges).

Where this applies

This domain's advice is contested more than most. These labels say what each claim is specific to — and where CONTESTED appears, the note gives the strongest form of the opposing view rather than a caricature.

  • LANGUAGE-SPECIFICIn Rust or Scala the compiler enforces both exhaustive matching and non-discarded results, so the design costs nothing beyond syntax. In TypeScript exhaustiveness needs a never assertion and non-discarding needs a lint rule, so the same design is a convention with two tooling props. In Python or Ruby neither exists and the guarantee reduces to a test suite — the argument for Result is materially weaker there, and reaching for it out of idiom rather than enforcement is cargo cult.
  • PARADIGM-SPECIFICIn a functional idiom Result composes with everything else the codebase already does with values. In an OO codebase built on exceptions, frameworks and libraries throw regardless, so a Result boundary is an island that must translate at both shores — the same technique with double the adapter code.
  • CONTESTEDThe strongest opposing case: exceptions keep the happy path readable and stop every intermediate frame from restating failures it does not handle, and in a codebase where ninety percent of failures are handled at one boundary anyway, Result adds ceremony to every frame to buy enforcement at one. Experienced engineers in Java and Python argue this and are frequently right; the counter is that the ninety-percent figure is an assumption nobody measures, and the ten percent is where the money was lost.

Where the depth lives

This domain teaches the codebase-level structure and hands the rest off.

Domains that do not exist yet
  • Programming Languages & Runtime Internals — sum types, exhaustiveness checking and must-use annotations are the language features this design is made of, and what a given compiler can prove is the ceiling on what the technique buys.