Make your
errors visible.
explicit_result brings
Result and Option types
to Python. Your function signatures become honest contracts.
Your error paths become impossible to ignore.
Installation
explicit_result has zero runtime dependencies. Install it and start using it
immediately.
# From PyPI pip install explicit-result # Or with dev tools (mypy, pyright, pytest) pip install explicit-result[dev]
Python 3.9 or later is required. No mypy plugins. No configuration files. No setup beyond the install itself.
Quick Start
Python functions can raise exceptions or return None — and neither appears in the type
signature. explicit_result gives you types that tell the truth.
Before
# What can go wrong here? # The signature won't tell you. def get_user(uid: int) -> User: # might raise DatabaseError # might raise UserNotFoundError # might return None silently ...
After
from explicit_result import Ok, Err, Result # The contract is explicit. def get_user(uid: int) -> Result[User, str]: row = db.find(uid) if row is None: return Err("user not found") return Ok(User.from_row(row))
Core types at a glance
from explicit_result import Ok, Err, Result, Some, Nothing, Option, safe # Result — a value OR an error def parse_port(raw: str) -> Result[int, str]: try: port = int(raw) except ValueError: return Err(f"Port must be an integer, got: {raw!r}") if not 1 <= port <= 65535: return Err(f"Port {port} is out of range") return Ok(port) parse_port("8080") # Ok(8080) parse_port("abc") # Err("Port must be an integer, got: 'abc'") parse_port("99999") # Err("Port 99999 is out of range") # Option — a value that might not exist def find_user(uid: int) -> Option[str]: users = {1: "Archy", 2: "Chuks"} return Some(users[uid]) if uid in users else Nothing find_user(1) # Some("Archy") find_user(99) # Nothing # @safe — wrap existing exception-throwing functions @safe(catch=ValueError) def parse_float(s: str) -> float: return float(s) parse_float("3.14") # Ok(3.14) parse_float("abc") # Err(ValueError(...)) # Bridge nullable values Result.from_optional(os.environ.get("PORT"), "PORT not set") # Ok("8080") or Err("PORT not set")
Result[T, E]
Result[T, E] represents a computation that either succeeds with a value of type T
(Ok), or fails with an error of type E (Err).
The success variant. Contains a value of type T. The operation completed without error.
The failure variant. Contains an error of type E. The error can be any type — string, enum,
exception, dataclass.
Result[int, str] means Ok contains int, Err contains str. Both
mypy and pyright understand this.
from explicit_result import Ok, Err, Result # Ok — success r: Result[int, str] = Ok(42) # Err — failure r: Result[int, str] = Err("something went wrong") # Errors can be any type r: Result[User, DatabaseError] = Err(DatabaseError("connection refused")) r: Result[str, list[str]] = Err(["field required", "invalid email"]) # Result.from_optional(value, error) — convert nullable r = Result.from_optional(os.environ.get("KEY"), "Missing env var")
Option[T]
Option[T] represents a value that may or may not exist.
It is an explicit, type-safe alternative to returning None.
Contains a value of type T. The value exists.
Some("Archy") # Some("Archy") Some(0) # Some(0) — 0 is a real value! Some([]) # Some([]) — empty list is real
A singleton. Represents the absence of a value. There is only one Nothing in memory — you
can use is comparisons safely.
Nothing # no value result is Nothing # True — singleton
Option.of(value) — A safe way to wrap any value.
Returns Nothing if the value is None, otherwise Some(value).
Option.of(None) # Nothing Option.of("data") # Some("data")
When to use Option vs Result
Use Option when absence is normal and expected — a user preference that might not be set, a cache entry that might not exist. The absence is not a failure.
Use Result when failure should carry a reason — a file that couldn't be read, a form field that failed validation. The caller needs to know why it failed.
Checking the Variant
Result
r = Ok(42) r.is_ok() # True r.is_err() # False r = Err("bad") r.is_ok() # False r.is_err() # True # With predicates — check variant AND value in one call Ok(10).is_ok_and(lambda x: x > 5) # True Ok(2).is_ok_and(lambda x: x > 5) # False Err("x").is_ok_and(lambda x: True) # False — predicate never called Err("bad input").is_err_and(lambda e: "input" in e) # True
Option
o = Some("hello") o.is_some() # True o.is_nothing() # False Nothing.is_some() # False Nothing.is_nothing() # True # NOTE: Boolean evaluation is DISABLED in v0.3.1 # if result: # Raises RuntimeError
Extracting Values
These methods let you get the contained value out of a Result or Option.
.unwrap()
Returns the value if Ok/Some. Raises UnwrapError if Err/Nothing. Use when you are logically
certain the result is ok — for example, right after checking is_ok(), or in a test.
Ok(42).unwrap() # 42 Err("x").unwrap() # raises UnwrapError: "Called unwrap() on an Err value: 'x'"
.unwrap_or(default)
Returns the value, or the provided default. The default is always evaluated — if computing it is
expensive, use unwrap_or_else.
Ok(42).unwrap_or(0) # 42 Err("x").unwrap_or(0) # 0 Nothing.unwrap_or("—") # "—"
.unwrap_or_else(f)
Returns the value, or calls f to compute a default. f is only called on failure.
Err("not found").unwrap_or_else(lambda e: f"default ({e})") # "default (not found)" Nothing.unwrap_or_else(lambda: expensive_default()) # f is never called when the result is Ok/Some
.unwrap_or_raise(exc)
Returns the value, or raises the given exception. Useful at API boundaries where you need to produce a specific exception type.
user = get_user(uid).unwrap_or_raise(HTTPException(status=404))
.expect(message)
Like .unwrap() but includes your message in the UnwrapError. Use to document
why this must be Ok.
config = load_config().expect( "Config must be loadable at startup — check your env vars" )
Transforming Values
.map(f) — transform the success value
Applies f to the Ok/Some value. If the result is Err/Nothing, it passes through unchanged and
f is never called.
Ok(5).map(lambda x: x * 2) # Ok(10) Ok("hello").map(str.upper) # Ok("HELLO") Err("bad").map(lambda x: x * 2) # Err("bad") — f not called Some("world").map(str.upper) # Some("WORLD") Nothing.map(str.upper) # Nothing — f not called
.map_err(f) — transform the error value
Applies f to the Err value. Ok passes through unchanged.
Err("not found").map_err(str.upper) # Err("NOT FOUND") Err(404).map_err(lambda c: f"HTTP {c}") # Err("HTTP 404") Ok(42).map_err(str.upper) # Ok(42) — f not called
.map_or(default, f) and .map_or_else(default_f, f)
Ok(5).map_or(0, lambda x: x * 2) # 10 Err("x").map_or(0, lambda x: x * 2) # 0 result.map_or_else( lambda e: f"Error: {e}", # called if Err lambda v: f"Value: {v}" # called if Ok ) # Option: .filter() — Some becomes Nothing if predicate fails Some(10).filter(lambda x: x > 5) # Some(10) Some(3).filter(lambda x: x > 5) # Nothing Nothing.filter(lambda x: True) # Nothing
Chaining
Chaining is the most powerful feature. It lets you write sequential logic that can fail at any step, without nested try/except blocks or if-None checks.
.and_then(f) — the core composition operator
If Ok/Some, calls f with the value and returns the result (which must itself be a
Result/Option). If Err/Nothing, returns immediately — f is never
called. Also known as flatmap or bind.
def parse_int(s: str) -> Result[int, str]: try: return Ok(int(s)) except ValueError: return Err(f"not a number: {s!r}") def ensure_positive(n: int) -> Result[int, str]: return Ok(n) if n > 0 else Err(f"must be positive, got {n}") # Railway-oriented: chain steps — the first failure short-circuits parse_int("5").and_then(ensure_positive).and_then(lambda n: Ok(n * 2)) # Ok(10) parse_int("-3").and_then(ensure_positive).and_then(lambda n: Ok(n * 2)) # Err("must be positive, got -3") — ensure_positive short-circuits parse_int("abc").and_then(ensure_positive).and_then(lambda n: Ok(n * 2)) # Err("not a number: 'abc'") — parse_int short-circuits
.or_else(f) — recover from failure
If Err/Nothing, calls f to attempt recovery. If Ok/Some, returns self unchanged.
# Try primary source, fall back to secondary value = ( get_from_cache(key) .or_else(lambda _: get_from_database(key)) .unwrap_or("default") ) # Option recovery Nothing.or_else(lambda: Some("fallback")) # Some("fallback")
Option extras — .zip() and .flatten()
# .zip() — combine two Somes into a tuple Some(1).zip(Some("a")) # Some((1, "a")) Some(1).zip(Nothing) # Nothing # .flatten() — unwrap Option[Option[T]] Some(Some(42)).flatten() # Some(42) Some(Nothing).flatten() # Nothing
Converting Between Types
Result → Option
Ok(42).ok() # Some(42) — success becomes Some Err("x").ok() # Nothing — error is discarded Err("x").err() # Some("x") — error becomes Some Ok(42).err() # Nothing — success discarded
Option → Result
Some(42).ok_or("missing") # Ok(42) Nothing.ok_or("missing") # Err("missing") Nothing.ok_or_else(lambda: build_error()) # Err(build_error())
Pattern Matching
explicit_result supports Python's structural pattern matching with no extra setup. The
__match_args__ attribute is set on all variants.
# Result match get_user(1): case Ok(user): print(f"Found: {user.name}") case Err(error): print(f"Error: {error}") # Option match find_config("theme"): case Some(value): apply_theme(value) case _: apply_default_theme() # Nested matching match (result_a, result_b): case (Ok(a), Ok(b)): process(a, b) case (Err(e), _) | (_, Err(e)): handle_error(e)
Error Context
Attach additional context to Err values without losing the original error cause. This works much
like Rust's .context(), chaining errors together in the traceback.
.context(message)
If Err, wraps the error in a ContextError with the given message. If Ok, passes through.
from explicit_result import ContextError def read_config() -> Result[str, Exception]: return read_file("config.json").context("Failed to load config") result = read_config() if result.is_err(): err = result.unwrap_err() print(type(err)) # <class 'explicit_result.ContextError'> print(err) # "Failed to load config" print(err.root_cause) # FileNotFoundError(...)
If you need to access the original error downstream, use the .root_cause()
helper on the Result (returns an Option) or the .root_cause
property on the ContextError.
result = read_file(path).with_context(lambda: f"Failed to read from {path}")
Diagnostic Visibility
explicit-result v0.3.1 features "Hybrid Representation" for errors, providing immediate diagnostic value.
1. Verbose View (str)
print(result) includes the full stack trace from the error origin.
2. Concise View (repr)
repr(result) provides a compact marker with file and line number.
Control verbosity via EXPLICIT_RESULT_VERBOSE_ERROR environment variable (1=Enabled, 0=Disabled).
@safe
Wraps an exception-throwing function into a Result-returning one. This is the bridge between
existing Python code (which uses exceptions) and explicit_result-style code.
from explicit_result import safe @safe(catch=ValueError) def parse_int(s: str) -> int: return int(s) # Returns Result[int, ValueError] parse_int("42") # Ok(42) parse_int("abc") # Err(ValueError("invalid literal...")) # Exceptions NOT in `catch` are re-raised — they are bugs parse_int(None) # raises TypeError (not caught, not a bug to swallow)
Multiple exception types
@safe(catch=(ValueError, KeyError)) def lookup_and_parse(data: dict, key: str) -> int: return int(data[key])
Safety rules enforced at decoration time
# FORBIDDEN — raises SafeDecoratorError immediately: @safe(catch=KeyboardInterrupt) # SafeDecoratorError! @safe(catch=SystemExit) # SafeDecoratorError! @safe(catch=GeneratorExit) # SafeDecoratorError! # WARNING — emits RuntimeWarning (too broad): @safe(catch=Exception) # works, but warns # Suppress the warning when you have a real reason: @safe(catch=Exception, allow_broad=True) # no warning
@safe_async
The async equivalent of @safe. Wraps an async def function.
import asyncio from explicit_result import safe_async @safe_async(catch=(aiohttp.ClientError, asyncio.TimeoutError)) async def fetch_json(url: str) -> dict: async with aiohttp.ClientSession() as session: async with session.get(url) as response: response.raise_for_status() return await response.json() # Returns Awaitable[Result[dict, aiohttp.ClientError | asyncio.TimeoutError]] result = await fetch_json("https://api.example.com/data") description = ( result .map(lambda data: data["weather"]["description"]) .unwrap_or("unavailable") )
@do / @do_option
Write sequential code without nesting by yielding Result or Option values. This
provides syntactic sugar similar to Haskell's do or Rust's ? operator.
Unlike some other libraries, explicit_result fully supports branching logic (if/else), loops, and early returns inside the generator, as it leverages standard Python generator semantics.
Safety Guard (v0.3.1): Using yield without the decorator issues a
RuntimeWarning to prevent leaked generators.
from explicit_result import do, Ok, Err, Result from typing import Generator, Any @do() def process_user(user_id: int) -> Generator[Result[Any, str], Any, Result[int, str]]: # Yielding a Result unwraps the Ok value. # If it's an Err, the generator early-returns that Err. user = yield get_user(user_id) profile = yield get_profile(user.profile_id) if not profile.is_active: # You can explicitly return early return Err("profile inactive") return Ok(profile.score) # The decorated function returns a Result, not a Generator! result = process_user(1) print(result) # Ok(100) or Err(...)
@do_option
The equivalent for Option chains. Yielding Nothing short-circuits the function.
from explicit_result import do_option, Some, Nothing, Option @do_option() def get_score(user_id: int) -> Generator[Option[Any], Any, Option[int]]: user = yield get_user(user_id) profile = yield get_profile(user.profile_id) return Some(profile.score)
Async Helpers
For bridging asynchronous tasks with synchronous Result pipelines.
from_awaitable(coro) / map_async / and_then_async
import asyncio from explicit_result import from_awaitable, map_async, and_then_async async def fetch_data() -> str: ... async def process(): # Wrap an awaitable that might raise into a Result res = await from_awaitable(fetch_data(), catch=Exception) # Chain async functions onto a Result async def async_transform(x: str) -> int: return len(x) async def async_fetch_more(x: int) -> Result[int, str]: return Ok(x * 2) mapped = await map_async(res, async_transform) chained = await and_then_async(mapped, async_fetch_more)
collect
Turns an iterable of Result values into a single Result containing a list. Returns
Ok([...]) if all results are Ok. Returns the first Err and stops
immediately (fail-fast).
from explicit_result import collect collect([Ok(1), Ok(2), Ok(3)]) # Ok([1, 2, 3]) collect([Ok(1), Err("bad"), Ok(3)]) # Err("bad") — stops here collect([]) # Ok([])
collect_all
Like collect, but gathers all errors instead of stopping at the first.
Essential for form validation where you want every field error in one pass.
from explicit_result import collect_all collect_all([Ok(1), Err("a"), Ok(3), Err("b")]) # Err(["a", "b"]) — all errors collected collect_all([Ok(1), Ok(2), Ok(3)]) # Ok([1, 2, 3])
# Form validation — report all errors at once results = [ validate_username(form.username), validate_email(form.email), validate_age(form.age), ] match collect_all(results): case Ok([username, email, age]): create_account(username, email, age) case Err(errors): show_errors(errors) # all validation errors at once
partition / sequence / transpose
partition — split into two lists
from explicit_result import partition oks, errs = partition([Ok(1), Err("a"), Ok(2), Err("b")]) # oks = [1, 2] # errs = ["a", "b"]
sequence — Option equivalent of collect
from explicit_result import sequence sequence([Some(1), Some(2), Some(3)]) # Some([1, 2, 3]) sequence([Some(1), Nothing, Some(3)]) # Nothing
transpose / transpose_result — flip the types
from explicit_result import transpose, transpose_result # Option[Result] → Result[Option] transpose(Some(Ok(42))) # Ok(Some(42)) transpose(Some(Err("x"))) # Err("x") transpose(Nothing) # Ok(Nothing) # Result[Option] → Option[Result] transpose_result(Ok(Some(1))) # Some(Ok(1)) transpose_result(Ok(Nothing)) # Nothing transpose_result(Err("x")) # Some(Err("x"))
Philosophy
A function that can fail should declare it in its return type. Callers must handle both cases to get the value.
Not a Haskell port. Not a Rust port. Every method name and design choice was made to feel natural to Python developers.
Use Result in one module, exceptions in another. Wrap existing code with @safe.
No big-bang rewrite required.
pip install explicit-result is the full setup. No plugins. No configuration. No transitive
dependencies.
What Belongs in Result
The most important question when using explicit_result: "Is this failure a valid state my program
should respond to — or is it evidence that my code is wrong?"
| Situation | Right tool | Why |
|---|---|---|
| File not found | Result | Normal operation — files may not exist |
| Network timeout | Result | Networks fail — this is expected |
| Invalid user input | Result | Users type wrong things — handle it |
| Business rule violation | Result | Domain logic can legitimately fail |
| Database row not found | Result or Option | Absence is a valid query outcome |
AttributeError — called method on None |
Let it crash | This is a bug in your code, not a runtime condition |
IndexError — index out of range |
Let it crash | Logic error — your bounds checking is wrong |
KeyboardInterrupt — user pressed Ctrl+C |
Let it propagate | Program-termination signal, not a recoverable error |
MemoryError — system out of memory |
Let it propagate | Program state may be undefined — cannot recover safely |
Real-World Patterns
Railway-oriented chaining
# Each step can fail. The first failure short-circuits the chain. result = ( parse_int(raw_input) # Step 1: parse .and_then(ensure_positive) # Step 2: validate .and_then(lookup_in_database) # Step 3: query .map(lambda row: row.to_dict()) # Step 4: transform .unwrap_or_else(lambda e: {"error": e}) )
Nested lookups with Option
# Without Option: nested if-None checks user = users.get(uid) if user is not None: dept = depts.get(user.dept_id) if dept is not None: lead = leaders.get(dept.lead_id) if lead is not None: email = lead.email # With Option: linear chain email = ( get_user(uid) .and_then(lambda u: get_dept(u.dept_id)) .and_then(lambda d: get_leader(d.lead_id)) .map(lambda lead: lead.email) .unwrap_or("no email") )
Collect all form errors
from explicit_result import collect_all errors = collect_all([ validate_username(form.username), validate_email(form.email), validate_age(form.age), validate_password(form.password), ]) # Either all pass — or you get ALL failures in one list if errors.is_err(): return Response({"errors": errors.unwrap_err()}, status=400)
Modern Integrations
FastAPI
Cleanly convert Result or Option to HTTPException.
from explicit_result.integrations.fastapi import unwrap_or_http @app.get("/items/{id}") def get_item(id: int): # Returns Result[Item, str] res = service.find(id) return unwrap_or_http(res, status_code=404)
Pydantic v2
Native support for fields in BaseModel. Supports validation and serialization.
class User(BaseModel): id: int nickname: Option[str] = Nothing balance: Result[float, str]
Performance Benchmarks
explicit-result adds negligible overhead compared to raw Python features (~300ns per operation).
| Pattern | Native Python | explicit-result | Overhead |
|---|---|---|---|
| Happy Path (Ok vs Return) | ~95ns | ~400ns | +305ns |
| Error Path (@safe vs try) | ~600ns | ~900ns | +300ns |
* Benchmarks run on Python 3.12.6
API Reference
Result[T, E]
Static constructors:
| Method | Returns | Description |
|---|---|---|
from_optional(v, e) |
(Optional[T], E) → Result[T, E] |
Ok(v) if v is not None, else Err(e) |
Instance methods:
| Method | Returns | Description |
|---|---|---|
is_ok() |
bool |
True if Ok variant |
is_err() |
bool |
True if Err variant |
is_ok_and(f) |
bool |
True if Ok and predicate passes |
is_err_and(f) |
bool |
True if Err and predicate passes |
unwrap() |
T |
Ok value or raises UnwrapError |
unwrap_or(default) |
T |
Ok value or default |
unwrap_or_else(f) |
T |
Ok value or f(error) |
unwrap_or_raise(exc) |
T |
Ok value or raises exc |
unwrap_err() |
E |
Err value or raises UnwrapError |
expect(msg) |
T |
Ok value or raises UnwrapError with msg |
expect_err(msg) |
E |
Err value or raises UnwrapError with msg |
map(f) |
Result[U, E] |
Transform Ok value; Err passes through |
map_or(default, f) |
U |
f(ok) or default |
map_or_else(df, f) |
U |
f(ok) or df(err) |
map_err(f) |
Result[T, F] |
Transform Err value; Ok passes through |
and_then(f) |
Result[U, E] |
Flatmap on Ok (chain) |
or_else(f) |
Result[T, F] |
Recover from Err |
context(msg) |
Result[T, ContextError] |
Add context to Err |
with_context(f) |
Result[T, ContextError] |
Add lazy context to Err |
and_(other) |
Result[U, E] |
Return other if Ok |
or_(other) |
Result[T, F] |
Return self if Ok, else other |
ok() |
Option[T] |
Convert to Option (Ok→Some, Err→Nothing) |
err() |
Option[E] |
Convert error to Option (Err→Some, Ok→Nothing) |
root_cause() |
Option[Any] |
Get the original error cause recursively |
Option[T]
| Method | Returns | Description |
|---|---|---|
is_some() |
bool |
True if Some variant |
is_nothing() |
bool |
True if Nothing |
is_some_and(f) |
bool |
True if Some and predicate passes |
unwrap() |
T |
Value or raises UnwrapError |
unwrap_or(default) |
T |
Value or default |
unwrap_or_else(f) |
T |
Value or f() |
unwrap_or_raise(exc) |
T |
Value or raises exc |
expect(msg) |
T |
Value or raises UnwrapError with msg |
map(f) |
Option[U] |
Transform Some value; Nothing passes through |
map_or(default, f) |
U |
f(value) or default |
map_or_else(df, f) |
U |
f(value) or df() |
filter(pred) |
Option[T] |
Some if predicate passes, else Nothing |
and_then(f) |
Option[U] |
Flatmap on Some (chain) |
or_else(f) |
Option[T] |
Recover from Nothing |
and_(other) |
Option[U] |
Return other if Some |
or_(other) |
Option[T] |
Return self if Some, else other |
zip(other) |
Option[(T,U)] |
Combine two Somes into a tuple |
flatten() |
Option[T] |
Flatten Option[Option[T]] |
ok_or(error) |
Result[T, E] |
Some→Ok, Nothing→Err(error) |
ok_or_else(f) |
Result[T, E] |
Some→Ok, Nothing→Err(f()) |
Combinators
| Function | Returns | Description |
|---|---|---|
collect(results) |
Result[List[T], E] |
All Ok or first Err (fail-fast) |
collect_all(results) |
Result[List[T], List[E]] |
All Ok or all Errs collected |
partition(results) |
(List[T], List[E]) |
Split into ok values and error values |
sequence(options) |
Option[List[T]] |
All Some or Nothing |
transpose(opt) |
Result[Option[T], E] |
Option[Result] → Result[Option] |
transpose_result(r) |
Option[Result[T, E]] |
Result[Option] → Option[Result] |
flatten_result(r) |
Result[T, E] |
Flatten Result[Result[T,E],E] |