Using returns in downstream library code
Basic returns usage (Success/Failure, .bind, @safe) is covered by training
data. This skill targets the edges that get missed: picking the right container,
keeping the IO/async markers, not bailing out of the container early, designing a
public API around it, and the pyrefly type-checker reality (returns ships a
mypy plugin that no other checker can run).
Pick the container by the kind of failure
The most common miss is collapsing distinct failure kinds onto Result. Match
the container to what can go wrong:
| Situation | Container | How to produce it |
|---|---|---|
Value may be absent (missing key, None, empty) — not an error |
Maybe[A] |
Maybe.from_optional(x), @maybe |
| Pure computation that may fail with info | Result[A, E] / ResultE[A] |
@safe, Success/Failure |
| Impure action that never fails (random, clock, env, print) | IO[A] |
IO(...), @impure |
| Impure action that may fail (network, disk, DB) | IOResult[A, E] / IOResultE[A] |
@impure_safe |
| Async action that may fail | FutureResult[A, E] / FutureResultE[A] |
@future_safe |
| Pure logic needing injected dependencies | RequiresContext[A, Deps] (+ ...Result, ...IOResult, ...FutureResult) |
construct with a lambda deps: ... |
Do not use @safe for impure work. A network/disk/DB call that can fail is
IOResultE, not ResultE — use @impure_safe. @safe says "pure but fallible";
using it on IO silently drops the IO marker that the whole library exists to
preserve. ResultE is the alias for Result[A, Exception]; IOResultE for
IOResult[A, Exception].
from returns.io import impure_safe, IOResultE
from returns.result import safe, ResultE
@impure_safe # ✅ network call: impure AND fallible
def fetch(user_id: int) -> dict:
resp = requests.get(f"/users/{user_id}"); resp.raise_for_status()
return resp.json()
# fetch(1) -> IOResultE[dict]
@safe # ✅ pure parse that may raise
def parse_age(raw: str) -> int:
return int(raw)
bind vs map — the cardinal rule
.map(f)—f: A -> B(plain function).bind(f)—f: A -> Container[B](function that itself returns the same container)
Using .map with a container-returning function nests the container
(Result[Result[B, E], E]) and the type checker won't always catch it under
pyrefly (see below). Using .bind with a plain function fails outright.
result.map(lambda x: x + 1) # A -> B
result.bind(parse_age) # A -> Result[B, E] (parse_age is @safe)
Crossing container types uses a typed bind_*, not .bind. .bind only
accepts the same container. To run a Result-returning step inside an
IOResult/FutureResult chain, use the bridge methods:
| In this container | Run a step returning... | Use |
|---|---|---|
IOResult |
Result |
.bind_result(f) |
IOResult |
IO |
.bind_io(f) |
FutureResult |
Result |
.bind_result(f) |
FutureResult |
IOResult |
.bind_ioresult(f) |
FutureResult |
a coroutine | .bind_async(f) |
Maybe |
Optional-returning fn |
.bind_optional(f) |
Stay in the container — don't unwrap early
.unwrap() raises on the failure case, .failure() raises on success, and
._inner_value is private. Reaching for any of them (or wrapping a container in
try/except) throws away the safety the container provides. Keep transforming
inside the container and collapse to a plain value only at the program edge.
# ❌ defeats the purpose
user = fetch(1).unwrap() # raises — back to exceptions
try:
user = fetch(1).unwrap()
except Exception: ...
# ✅ stay in the container, collapse once at the boundary
message = (
enrich_user(1)
.map(lambda email: f"Email: {email}")
.value_or("no email found") # total: provides the failure branch
)
At the boundary use, in order of preference:
.value_or(default)— one default for the failure branch..lash(handler)— failure-sidebind(recover into another container).match/is_successful(x)— when both branches need real handling..unwrap()— only in tests, or right after you've already folded every failure into the success branch.
Bridging between containers — use the converters, don't hand-roll
A frequent miss is reinventing Maybe -> Result as
m.map(Success).value_or(Failure(err)). The library ships the conversions:
from returns.converters import maybe_to_result, result_to_maybe, flatten
maybe_to_result(maybe_email, ValueError("no email")) # Maybe[A] -> Result[A, E]
result_to_maybe(some_result) # Result[A, E] -> Maybe[A]
flatten(Success(Success(1))) # Container[Container[A]] -> Container[A]
is_successful(container) is the boolean predicate; partition(containers) (from
returns.methods) splits an iterable of containers into ([successes], [failures]).
Composition: do-notation first, then flow/pointfree
For multi-step pipelines, prefer do-notation — it reads like imperative code
and avoids lambda soup. Each container type has its own .do:
from returns.result import Result, Success, Failure
final: Result[int, str] = Result.do(
x + y
for x in parse("1") # each `for` binds a Result; first Failure short-circuits
for y in parse("2")
)
flow(value, f1, f2, ...) threads a value left-to-right; pipe(f1, f2, ...)
builds the function without a seed. With containers, the steps are pointfree
helpers from returns.pointfree (bind, map_, bind_result, bind_io,
bind_async, lash, alt, ...), because flow can't call .bind for you:
from returns.pipeline import flow
from returns.pointfree import bind, map_
flow(user_id, fetch, bind(parse), map_(format_name))
Note: map_ has a trailing underscore (avoids shadowing builtin map). Bare
bind/map_ from pointfree act on the first container slot generically — see
the HKT note below for the pyrefly caveat.
Async with Future / FutureResult
@future_safe turns an async def into a FutureResultE. Compose entirely
inside FutureResult (it stays lazy and short-circuits on first failure), then
cross the async boundary exactly once:
from returns.future import future_safe, FutureResultE
from returns.io import IOResult
from returns.unsafe import unsafe_perform_io
import anyio
@future_safe
async def fetch_record(rid: int) -> dict:
async with httpx.AsyncClient() as c:
r = await c.get(f"/records/{rid}"); r.raise_for_status()
return r.json()
def process(rid: int) -> FutureResultE[str]:
return fetch_record(rid).bind_result(extract_field) # Result step lifted in
def run(rid: int):
# awaiting a FutureResult yields an IOResult, not a bare value.
io_result: IOResult = anyio.run(process(rid).awaitable)
return unsafe_perform_io(io_result) # peel IO only at the true edge
Key facts agents miss: awaiting a FutureResult gives an IOResult (the
async IO happened, so the marker is preserved); unsafe_perform_io is the
only sanctioned way to strip IO, and only at the program edge. Don't
asyncio.run a raw Future and expect a plain value.
Public API design: return the container, don't leak it
For a library's public surface:
- Return
Result/Maybe/IOResultso callers see the failure mode in the type and compose with their own pipelines. Don't.unwrap()internally and re-raise — that erases the contract. - Use
ResultE/IOResultEaliases in signatures; they're shorter and signal "errors are exceptions" clearly. - Keep the IO marker in the type. If the function does impure work, its
public return type must be
IO*/Future*. Hiding it behindResultlies to callers about purity. - Pick a deliberate error type.
@safe/@impure_safecapture any exception by default; narrow with@safe(exceptions=(ValueError, KeyError))when the contract should be specific.
pyrefly + returns: the mypy plugin does NOT apply
This is the highest-value, least-obvious part. returns ships a mypy plugin
(returns.contrib.mypy.returns_plugin) precisely because its emulated
Higher-Kinded-Types encoding (KindN/Kind1/Kind2/Kind3, @kinded,
dekind) cannot be resolved by a structural type checker alone. pyrefly has no
plugin system (it hardcodes support for a fixed set like Pydantic/Django), so
under pyrefly that machinery runs unassisted.
What this means concretely (verified against pyrefly 1.0):
| Construct | Under pyrefly (no plugin) |
|---|---|
r.map(f), r.bind(f) on a concrete container (Result, IOResult, Maybe) |
✅ Correct — these declare concrete return types directly |
flow(...) / pipe(...) |
⚠️ Degrades to Unknown — pipeline inference is plugin-only (no error, lost precision) |
Container.do(...) |
⚠️ Value type kept, error type lost (Result[int, Unknown]) |
pointfree bind(f), map_(f) |
❌ Leaks KindN[...] — assigning the result to Result[...] raises bad-assignment; calling .map on it raises not-callable |
your own @kinded / KindN-generic helpers |
❌ Same KindN leakage and false errors |
Practical guidance for pyrefly projects:
- Prefer method chaining on concrete containers (
x.bind(f).map(g)) and do-notation over pointfreeflow/bind/map_. The concrete methods type cleanly; pointfree is where the false positives live. - In your own public signatures, annotate with concrete container types
(
Result[int, str],IOResultE[dict]) rather thanKindN/@kindedgenerics unless you genuinely need HKT polymorphism — pyrefly can't check the latter. - Suppress the residual false positives surgically, not globally:
applied: Result[int, str] = bind(parse)(r) # pyrefly: ignore[bad-assignment]pyrefly suppresscan auto-insert these. - Recommended
pyproject.tomlbaseline:
Only if pointfree/HKT usage is pervasive and inline ignores become noisy, add:[tool.pyrefly] # don't type-check inside returns' own HKT machinery project-excludes = ["**/site-packages/returns/**"]
Last resort (gives up all checking of returns, including the parts that work):[tool.pyrefly.errors] bad-assignment = false # broad — also silences real assignment errors not-callable = false[tool.pyrefly] replace-imports-with-any = ["returns.*"]
There is no pyrefly config that restores HKT inference — only the mypy plugin does that. If full HKT type-safety matters, run mypy (with the plugin) alongside pyrefly, or stay on mypy for that codebase.
Common mistakes
| Mistake | Fix |
|---|---|
@safe on a network/disk/DB call |
Use @impure_safe → IOResultE; impure work keeps the IO marker |
.map(f) where f returns a container |
Use .bind(f) (or .bind_result/bind_io/... to cross types) |
.bind(f) where f returns a plain value |
Use .map(f) |
.unwrap() / ._inner_value / try/except to get the value |
Stay in the container; .value_or / .lash / match at the edge only |
Hand-rolling m.map(Success).value_or(Failure(e)) |
maybe_to_result(m, e) from returns.converters |
.bind to go IOResult → Result step |
.bind_result(f) (the typed bridge) |
asyncio.run(some_future) expecting a value |
await a FutureResult → IOResult; unsafe_perform_io at the edge |
| Library internally unwraps then re-raises | Return the Result/IOResult; let callers compose |
Pyrefly errors on pointfree bind/map_ ("not callable", "bad assignment") |
Expected — no mypy plugin under pyrefly; use concrete-method chaining or # pyrefly: ignore[...] |
Annotating helpers with KindN/@kinded in a pyrefly project |
Use concrete container types unless HKT polymorphism is truly required |