| name | chaining-returns-results |
| description | Use when composing or chaining dry-python returns Result / FutureResult values โ sequencing multi-step operations where each step can fail, recovering from a specific error, or deciding where a Result becomes a plain value, exit code, or response. Also when reaching for .unwrap(), .failure(), ._inner_value, isinstance(r, Success) / is_successful() mid-pipeline, match on Success/Failure outside the consumption edge, Result.do, or try/except around Result-returning code. |
Chaining returns Results
Overview
Once a value is a Result/FutureResult, keep it one. Compose every step with combinators (.bind / .map / .alt / .lash / .map(tap(...))) and collapse exactly once, at the consumption edge, with a match on Success(v) / Failure(e) โ turning the chain into the outside-world value (CLI exit code, HTTP response, rendered output). The error channel stays a typed union the whole way; the edge match is where โ and the only place where โ it collapses.
The application boundary owns the collapse; everything beneath it returns Results onward. RELATED: precise-type-modeling owns the error union itself (frozen dataclass variants / Literal tags); branching-modeled-state-with-match owns exhaustive matching at the edge.
The combinators (the whole vocabulary)
| Combinator | Use for |
|---|
.bind(fn) | Next step that can itself fail (fn returns a Result/FutureResult). Short-circuits on Failure. |
.map(fn) | Transform the success value (cannot fail). |
.alt(fn) | Normalize the error โ e.g. into the shared AppError union so the final match is exhaustive. |
.lash(fn) | Recover from an error: return Success(fallback) for the case you handle, Failure(e) to re-propagate the rest. |
.map(tap(fn)) | Fire a side-effect (notify, log) without changing the value (returns.functions.tap). |
@safe(exceptions=(XError,)) | Bring throwing code into the chain โ the one place an exception is caught. Follow with .alt into your error union. |
@future_safe / FutureResult | Async steps. FutureResult.from_result(r) bridges a sync Result into an async chain; .bind_result(fn) binds a sync-Result-returning step. |
The recipe
from returns.functions import tap
from returns.result import Failure, Result, Success
def run(raw: object) -> Result[Done, AppError]:
def with_record(inp: Input) -> Result[tuple[Input, Record], AppError]:
return load(inp.id).map(lambda rec: (inp, rec))
def recover(e: AppError) -> Result[Done, AppError]:
return Success(FALLBACK) if isinstance(e, RecoverableError) else Failure(e)
return (
parse(raw)
.bind(with_record)
.bind(lambda pair: act(*pair).lash(recover))
.map(tap(lambda _: notify()))
.bind(lambda done: save(done).map(lambda _: done))
)
Consume once, at the edge โ the only match:
def main() -> int:
match run(read_argv()):
case Success(done):
print(render(done))
return 0
case Failure(err):
print(err.message, file=sys.stderr)
return err.exit_code
.value_or(default) is fine at the edge when a default is the entire error story.
Where the edge is
The collapse belongs only where the Result leaves your code for the outside world: a CLI main (exit code), an HTTP handler (response), a top-level render/effect, or a test assertion (tests may also use is_successful() / .unwrap()). Service/domain/helper layers return the Result onward โ they never collapse.
Anti-patterns
| Instead of | Do |
|---|
match on Success/Failure mid-pipeline, then re-wrapping in Success/Failure | .bind (success path) / .lash (recovery) |
.unwrap() / .failure() / ._inner_value in production code | Carry the Result; collapse only at the edge |
if not is_successful(r): return r then r.unwrap() mid-flow | .bind (is_successful/.unwrap is for tests, not production flow) |
try/except around a Result-returning call | Nothing to catch โ it doesn't raise. Chain it. |
try/except around throwing third-party code mid-pipeline | A @safe(exceptions=...) adapter at the boundary, .alt into your union, then chain |
Result.do(... for x in r ...) generator do-notation | Chain with .bind / .lash โ this is the project's style |
Red Flags โ STOP
- About to
match/destructure a Result somewhere that is not the consumption edge โ use .bind/.lash.
- About to call
.unwrap(), .failure(), or touch ._inner_value outside a test.
- About to write
isinstance(r, Success) or is_successful(r) to branch in production flow โ keep chaining.
- About to reach for
Result.do โ use combinators.
- A
try/except wrapping code that already returns a Result โ wrap the throwing call once with @safe, not the chain.