skip to content

When should a function return a Result/Either type instead of throwing an exception, and what are the trade-offs of each style?

level: seniorimportance: should knowfreq 48%

answer

  1. Result = failure as a value in the signature; exception = invisible jump
  2. Expected outcome -> Result; bug/infrastructure -> throw
  3. Result composes and can accumulate errors
  4. Price: manual propagation, signature churn, no stack trace
  5. Checked exceptions = the earlier attempt; convert at boundaries

basics

~20 s

Throw for unexpected failures the caller usually can't handle locally — bugs, infrastructure outages. Return a Result/Either when failure is an expected outcome the caller must decide about, because it puts the failure in the type signature and can't be forgotten.

solid answer

~50 s

A `Result<T, E>` (or `Either<E, T>`) is a value that is either a success carrying `T` or a failure carrying an error `E`. Its advantages: the possibility of failure is visible in the signature; the compiler/linter forces you to unwrap; there's no hidden control flow; and errors compose in pipelines (map/flatMap short-circuits on the first failure). It is also cheap — no stack capture — so it suits high-frequency failures and hot paths. Costs: it must be threaded through by hand up every frame (exceptions propagate free); it clutters signatures in deep call chains; it needs discipline to avoid an error type that grows into a union of everything; and it doesn't carry a stack trace unless you add one. Practical split: Result for *domain* outcomes — validation, business rules, parsing, "not found"; exceptions for programmer errors and infrastructure failure. Convert between them at boundaries, and pick one style per layer rather than mixing arbitrarily.

code

pseudocode · 15 lines
pseudocode
// Result: failure visible in the signature, composes, short-circuits
function parseOrder(json): Result<Order, OrderError>

parseOrder(body)
    .flatMap(validateItems)      // stops at first Err
    .flatMap(reserveStock)
    .fold(
        ok  -> respond(201, ok),
        err -> respond(statusFor(err), problem(err))   // exhaustive over sealed OrderError
    );

// Exception: caller can't do anything locally; one boundary handler decides
function chargeCard(order) {          // throws PaymentGatewayUnavailable
    ...
}

go deeper

for a junior

Say: throw for genuinely unexpected problems; return a Result/Optional when the caller is expected to handle 'this may fail' — the type makes it impossible to forget.

for a middle

Contrast automatic vs manual propagation, cost of throwing, and give the taxonomy: bugs and infrastructure throw, domain outcomes return values.

for a senior

Discuss composition and error accumulation, signature churn vs the checked-exception lineage, lost stack traces, and where to place the conversion seam between Result-based core and throwing adapters.

for a principal

Set it as a codebase policy: a per-module sealed error taxonomy, one conversion boundary per layer, transport mapping and retryability semantics derived from the error type, plus tooling (lints for unused Results, exhaustiveness checks) to keep the policy from eroding.

## Definitions first - **Exception**: a non-local transfer of control. `throw` abandons the current call and unwinds the stack until a matching handler is found. Propagation is automatic; failure is invisible in the signature (in most languages). - **Result / Either**: an ordinary value with two shapes — `Ok(value)` / `Err(error)`, or `Right(value)` / `Left(error)`. Failure is data. Propagation is manual (or sugared: Rust's `?`, Kotlin's `getOrElse`, Haskell's do-notation, `flatMap` chains). - **Optional / Maybe**: the degenerate Result where the failure carries no information — good for "absent", not enough when the caller needs to know *why*. ## What Result buys you **1. Honesty in the signature.** `fun parse(s: String): Result<Config, ParseError>` states the contract. `fun parse(s: String): Config` that secretly throws does not. Reviewers and IDEs can see the failure mode without reading the body. **2. Unforgettable.** You cannot use the success value without unwrapping, and idiomatic APIs make ignoring an error awkward (Rust warns on unused `Result`). Compare an undeclared runtime exception: nothing prompts the caller. **3. Explicit, local control flow.** No invisible jumps. Reading a Result-based function tells you every point that can end it. This matters most in code with tight resource or state invariants. **4. Composition.** In a pipeline, `map`/`flatMap`/`andThen` chains short-circuit at the first failure and carry it to the end, so multi-step flows read linearly without nesting. Some libraries also support *accumulating* errors (Validation applicatives) — collecting all field errors at once rather than stopping at the first, which exceptions cannot do naturally and forms are always asking for. **5. Cost.** Constructing a Result is an allocation at worst; throwing typically captures a stack trace and unwinds, which is one to three orders of magnitude more expensive. Where failures are common (parsing untrusted input, cache misses, validation of user forms), that difference is real. **6. Testability and totality.** Exhaustive matching over a sealed error type means adding a new error case produces a compile error at every decision point — the compiler enumerates your handling gaps. ## What Result costs you **1. Manual plumbing.** Every intermediate frame must propagate. In a 10-frame stack where only frames 1 and 10 care, exceptions cost nothing and Result costs ten unwraps (less with `?`-style sugar, but the signatures still change). **2. Signature churn.** A new error case in a leaf function ripples through every enclosing signature's error type — the same Open/Closed complaint levelled at checked exceptions. Mitigate with a small, stable error enum per module, or a boxed/erased error type at boundaries. **3. Error-type design is hard.** Left unmanaged, `E` becomes a giant union that every caller matches on with a wildcard, at which point you have reinvented `catch (Exception)` with more ceremony. **4. No stack trace by default.** Great for performance, bad for diagnosing a rare failure. Production systems usually attach context deliberately (an error chain with `source()`, added context strings, or an id) — you trade automatic traces for curated ones. **5. Language fit.** In languages with no sum types or pattern matching, hand-rolled Results are clumsy and teams drift back to nullable fields and boolean flags. The library ecosystem matters too: if every dependency throws, a pure-Result layer needs an adapter at every call. **6. Catastrophic failures don't fit.** Out-of-memory, stack overflow, assertion violations, cancellation — you don't want `Result` for these; you want to unwind. Even Rust, the archetypal Result language, keeps `panic!` for unrecoverable bugs. ## Choosing — the practical taxonomy | Failure kind | Example | Mechanism | |---|---|---| | Programmer error / broken invariant | null arg, index out of range, impossible state | Throw / panic. Don't model it; fix it. | | Expected domain outcome | validation failed, not found, insufficient funds | Result / sealed outcome type | | Frequent, high-volume failure | parsing untrusted input in a loop | Result (throw cost dominates) | | Transient infrastructure failure | DB timeout, HTTP 503 | Exception, often wrapped; retry policy at the boundary | | Truly unrecoverable | OOM, corrupted state | Let it propagate and kill the unit of work | A useful test: *does the immediate caller have a meaningful decision to make?* If yes, a value forces them to make it. If the answer is "nothing sensible except abort and report", an exception plus one boundary handler is less code and equally safe. ## Mixing the two safely Most real systems use both. Keep the seam explicit: - The **domain/core** returns Results (pure, testable, no I/O, no throwing). - **Adapters** talk to libraries that throw, catch at that edge, and convert to Result — or wrap into a domain exception if it's genuinely infrastructural. - The **outer boundary** (HTTP handler, consumer, CLI) collapses both worlds into one transport representation: Result failures map to 4xx-ish business responses, exceptions to 5xx-ish, with one place doing the mapping, one log line, and a correlation id. What to avoid: a function returning `Result` that also throws for some cases (the caller must now handle both), and 'Result-washing' — catching an exception, discarding the cause, and returning `Err("failed")`. ## Historical note This is not a new debate. Checked exceptions were Java's attempt to put failures in the signature; they were widely judged too rigid because a new checked exception breaks every signature up the chain and callers respond by wrapping in `RuntimeException` or writing empty catches. Result types answer the same goal — visible, unforgettable failure — with better composition and opt-in propagation, and pay for it in plumbing. Understanding that lineage is usually what an interviewer is probing.

  • How do you stop the error type E from becoming a union of everything?
    Give each module a small sealed error type meaningful to its callers, and map (not merge) at boundaries: a lower module's error becomes one case of the higher one, carrying the original as a nested source. If callers only ever wildcard-match, the type is too wide and should be collapsed to a couple of decision-relevant cases plus a structured payload.
  • What do you lose in observability by returning Results instead of throwing?
    The automatic stack trace. You have to attach context deliberately — a source/cause chain, contextual strings added as the error moves up, and a correlation id logged at the boundary. The upside is curated, low-noise diagnostics instead of megabyte traces; the risk is an `Err("failed")` with no provenance at all.
  • Can you accumulate multiple validation errors with exceptions?
    Not naturally — the first throw ends the computation, so you'd have to collect errors in a list yourself and throw once at the end, which is really the Result pattern in disguise. Applicative-style validation over Results accumulates all field errors in one pass, which is why form/DTO validation is a canonical Result use case.

An exception is pulling the emergency cord: the train stops and someone up the line deals with it. A Result is a routing slip attached to the package — it travels with the item, and whoever handles it next must read it before they can do anything with the contents.

saying these in an interview costs you the question

  • "Result types are just checked exceptions again" — they propagate on demand and compose; the failure modes differ
  • A function that returns Result *and* also throws for some inputs
  • Catching an exception and returning Err("failed") with the cause discarded
  • Using Result for out-of-memory, cancellation or assertion failures instead of letting them unwind
  • Wrapping every call in Result 'for consistency' in a language with no pattern matching or propagation sugar
  • Ignoring an unused Result (or force-unwrapping every one) — reintroduces the ignorability problem

context