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.
When is it worth making failure part of the return type rather than a separate control-flow path?
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.
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.
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.
- 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.
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.
- The codebase is TypeScript; there are no sum types with exhaustive matching in the language, only discriminated unions plus a
nevertrick that engineers must remember to write. - The async story matters: every payment call is a promise, and a
Resultinside aPromiseis 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).
- 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.
- 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
Resultinto 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).
- The
Resultboundary should coincide with the domain boundary: inside a module, plain values and exceptions for bugs; crossing out of it, aResultthat names the expected failures. - Wrapping everything in
Resultall 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 insideResult<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.
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 restating10 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 error19 }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.
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 warningfunction 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 CIThe 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.
| Option | Simplicity | Flexibility | Testability | Migration cost | Operational | Note |
|---|---|---|---|---|---|---|
| Exceptions everywhere | Cleanest 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 boundaries | Failures 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 everywhere | Maximum 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 inEwould be a bug when it happens, it does not belong there (An Error Taxonomy That Survives Contact). - Make
Ea closed union so the caller'sswitchcan be exhaustive, and add thenever-assignment exhaustiveness check in languages that do not enforce it for you. - Make ignoring the result mechanically detectable —
@typescript-eslint/no-floating-promisesplus a lint rule on unused returns, or#[must_use]in a language that has it. Without that,Resultis 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
Resultthat 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.
- Adding a new failure case to
Ecosts 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
Resultafter 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
Ecosts 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.
- 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
- 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.
EbecomesErrororstring, 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.
- Callers depend on the specific
Eof 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
Epropagates outward through every signature that returns it, which is either honest accounting or a refactoring tax depending on your mood. - The
Resulttype itself is a leaf dependency and must stay one; aResultthat knows about HTTP has already failed (Dependency Direction).
- "Results are safer than exceptions." Not inherently. A
Resultthat 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
Eare 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).
- swallowed-errors
- primitive-obsession
Testing it, and how it ages
- Test that each failure case is *reachable and returned*, not merely representable — a case in
Eno 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).
Egrows 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
Einto 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
neverassertion 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 forResultis materially weaker there, and reaching for it out of idiom rather than enforcement is cargo cult. - PARADIGM-SPECIFICIn a functional idiom
Resultcomposes with everything else the codebase already does with values. In an OO codebase built on exceptions, frameworks and libraries throw regardless, so aResultboundary 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,
Resultadds 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.
- — 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.