Result and Option types for Python — zero dependencies, fully typed.
Python functions lie. A function typed as -> int might return an integer, raise a ValueError, raise a ConnectionError, or return None depending on conditions the caller cannot see. The type system gives you no warning. You discover the truth at runtime, usually in production.
explicit-result fixes this by making errors visible in the function signature itself.
from explicit_result import Ok, Err, Result
def divide(a: float, b: float) -> Result[float, str]:
if b == 0:
return Err("division by zero")
return Ok(a / b)
result = divide(10, 2) # Ok(5.0)
result = divide(10, 0) # Err("division by zero")The signature Result[float, str] is a contract: "I will give you either a float or a string error. I will not surprise you."
- Installation
- Quick Start
- Core Concepts
- Safety & Reliability (v0.3.1)
- Result[T, E]
- Option[T]
- Do-Notation
- Error Context
- Decorators
- Async Helpers
- Combinators
- Real-World Patterns
- Error Handling Philosophy
- Type System Integration
- API Reference
- FAQ
- Contributing
- License
pip install explicit-resultexplicit-result has zero dependencies. It requires Python 3.9 or later.
For Python 3.10+ you get full structural pattern matching support automatically.
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 valid range (1–65535)")
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 valid range (1–65535)")
# --- Option: a value that might not exist ---
def find_user(user_id: int) -> Option[str]:
users = {1: "Archy", 2: "Chuks"}
return Some(users[user_id]) if user_id in users else Nothing
find_user(1) # Some("Archy")
find_user(99) # Nothing
# --- @safe: wrap existing 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("could not convert string to float: 'abc'"))
# Bridge nullable values
Result.from_optional(os.environ.get("PORT"), "PORT not set")
# Ok("8080") or Err("PORT not set")Exceptions are Python's built-in error mechanism and they work — but they have a fundamental flaw: they are invisible in type signatures.
# What does this function signature tell you?
def get_user(user_id: int) -> User:
...The answer is: almost nothing about failure. This function might:
- Return a
Userobject - Raise
UserNotFoundError - Raise
DatabaseConnectionError - Raise
PermissionError
You would only know by reading the implementation, the docstring (if it's accurate), or by getting surprised in production. The type system provides no help.
Exceptions are also invisible to control flow tools. A linter cannot tell you that you forgot to handle DatabaseConnectionError. A type checker cannot warn you that you're calling .name on something that might not exist. The knowledge lives in the programmer's head.
Returning None for failure is tempting but loses the error reason entirely:
def find_config(path: str) -> dict | None:
...
config = find_config("/etc/app.conf")
# config is None — but WHY? File missing? Permission denied? Invalid syntax?
# We will never know.None is also a valid value in many contexts, which creates ambiguity. And dict | None still tells callers nothing about why the operation failed.
explicit_result is built on three ideas:
1. Errors should be part of the contract.
A function that can fail should declare it in its return type. Result[User, DatabaseError] says exactly what can happen. Callers must handle both cases to get the value.
2. The API should feel native to Python.
explicit_result is not a Haskell or Rust port. Every method name, every pattern, and every design choice was made to feel natural in Python code.
3. Adopt incrementally.
You can use Result in one module and plain exceptions in another. You can wrap existing exception-throwing code with @safe. You do not need to rewrite your entire codebase to benefit.
Result[T, E] represents a computation that either succeeds with a value of type T, or fails with an error of type E. There are two variants: Ok(value) and Err(error).
from explicit_result import Ok, Err, Result
# Success
r: Result[int, str] = Ok(42)
# Failure
r: Result[int, str] = Err("something went wrong")
# In a function
def safe_divide(a: float, b: float) -> Result[float, str]:
if b == 0:
return Err("cannot divide by zero")
return Ok(a / b)The type parameters are optional but strongly recommended. With Result[int, str], both mypy and pyright know that the Ok value is an int and the Err value is a str. Without them, type inference still works but provides less precision.
r = Ok(42)
r.is_ok() # True
r.is_err() # False
r = Err("bad")
r.is_ok() # False
r.is_err() # TrueFor conditional checks that also inspect the value:
# is_ok_and — True only if Ok AND value satisfies predicate
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 — never calls predicate
# is_err_and — True only if Err AND error satisfies predicate
Err("bad input").is_err_and(lambda e: "input" in e) # True
Ok(1).is_err_and(lambda e: True) # False.unwrap() — Returns the Ok value. Raises UnwrapError if the result is Err. Use this only when you are logically certain the result is Ok, for example immediately after an is_ok() check, or in tests.
Ok(42).unwrap() # 42
Err("x").unwrap() # raises UnwrapError: "Called unwrap() on an Err value: 'x'".unwrap_or(default) — Returns the Ok value, or the provided default if Err. The default is always evaluated, even if the result is Ok. If computing the default is expensive, use unwrap_or_else.
Ok(42).unwrap_or(0) # 42
Err("x").unwrap_or(0) # 0.unwrap_or_else(f) — Returns the Ok value, or calls f with the error and returns the result. The function f is only called when the result is Err.
Err("file not found").unwrap_or_else(lambda e: f"default (reason: {e})")
# "default (reason: file not found)"
Ok(42).unwrap_or_else(lambda e: 0) # 42 (f is never called).unwrap_or_raise(exc) — Returns the Ok value, or raises the given exception. Useful when you want to convert an Err back into a specific exception at a boundary.
result.unwrap_or_raise(HTTPException(status_code=404)).unwrap_err() — Returns the Err value. Raises UnwrapError if the result is Ok. Primarily useful in tests.
Err("bad").unwrap_err() # "bad"
Ok(1).unwrap_err() # raises UnwrapError.expect(message) — Like .unwrap() but includes a custom message in the UnwrapError. Use this to document why a value must be Ok at this point.
config = load_config().expect(
"Config must be loadable at startup — check your environment variables"
).expect_err(message) — Like .unwrap_err() but with a custom message.
.map(f) — Applies f to the Ok value. Returns a new Ok with the result. If the result is Err, it is returned unchanged — 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.map_or(default, f) — Applies f to Ok value, or returns default for Err.
Ok(5).map_or(0, lambda x: x * 2) # 10
Err("bad").map_or(0, lambda x: x * 2) # 0.map_or_else(default_f, f) — Applies f to Ok, or calls default_f with the error for Err. Both functions are only called when their case applies.
result.map_or_else(
lambda e: f"Error: {e}", # called if Err
lambda v: f"Value: {v}" # called if Ok
).map_err(f) — Applies f to the Err value. Returns a new Err with the result. If the result is Ok, it is returned unchanged — f is never called.
Err("not found").map_err(str.upper) # Err("NOT FOUND")
Err(404).map_err(lambda code: f"HTTP {code}") # Err("HTTP 404")
Ok(42).map_err(str.upper) # Ok(42) — f not calledThis is useful for converting between error types — for example, converting a low-level OSError into a domain-specific error type.
The most powerful feature of Result. Chaining lets you write sequential logic that can fail at any step, without nested try/except blocks.
.and_then(f) — The core composition operator. If the result is Ok, calls f with the value and returns whatever f returns (which must itself be a Result). If the result is Err, returns it unchanged — f is never called.
This is also known as flatmap or bind in other languages.
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}")
def double(n: int) -> Result[int, str]:
return Ok(n * 2)
# Chaining all three:
result = (
parse_int("5")
.and_then(ensure_positive)
.and_then(double)
)
# Ok(10)
result = (
parse_int("-3")
.and_then(ensure_positive) # short-circuits here with Err("must be positive, got -3")
.and_then(double) # never called
)
# Err("must be positive, got -3")
result = (
parse_int("abc") # short-circuits here with Err("not a number: 'abc'")
.and_then(ensure_positive) # never called
.and_then(double) # never called
)
# Err("not a number: 'abc'").or_else(f) — The recovery operator. If the result is Err, calls f with the error and returns whatever f returns (a Result). If the result is Ok, returns it unchanged.
# Try primary source, fall back to secondary
def get_from_cache(key: str) -> Result[str, str]: ...
def get_from_database(key: str) -> Result[str, str]: ...
value = (
get_from_cache("user:1")
.or_else(lambda e: get_from_database("user:1"))
.unwrap_or("default")
).and_(other) — Returns other if self is Ok, otherwise returns self (the Err). Useful when you want to sequence operations but only care about the second result.
Ok(1).and_(Ok(2)) # Ok(2)
Err("x").and_(Ok(2)) # Err("x") — other is discarded
Ok(1).and_(Err("y")) # Err("y").or_(other) — Returns self if Ok, otherwise returns other. A simple fallback.
Ok(1).or_(Ok(99)) # Ok(1)
Err("x").or_(Ok(99)) # Ok(99)
Err("x").or_(Err("y")) # Err("y").ok() — Converts to Option. Ok(v) becomes Some(v), Err becomes Nothing. The error value is discarded.
Ok(42).ok() # Some(42)
Err("x").ok() # Nothing.err() — Converts to Option. Err(e) becomes Some(e), Ok becomes Nothing.
Err("x").err() # Some("x")
Ok(42).err() # NothingOn Python 3.10+, Result supports structural pattern matching via match:
result = divide(10, 2)
match result:
case Ok(value):
print(f"Success: {value}")
case Err(error):
print(f"Failed: {error}")This is exhaustive — if you handle both Ok and Err, the type checker knows that every case is covered.
In v0.3.1, Result and Option no longer support implicit boolean evaluation. Using if result: will raise a RuntimeError. This prevents subtle bugs where a container carrying a falsy value (like Ok(False)) is misinterpreted.
You must use explicit methods:
result = parse_int("42")
# REQUIRED: Use .is_ok() or .is_err()
if result.is_ok():
value = result.unwrap()
else:
value = 0Result still supports iteration. Ok(v) yields v once. Err yields nothing.
This is useful for flattening a list of results:
results = [Ok(1), Err("skip"), Ok(3), Ok(4), Err("skip")]
values = [x for r in results for x in r]
# [1, 3, 4]Option[T] represents a value that may or may not exist. It is an explicit, type-safe alternative to returning None. There are two variants: Some(value) and Nothing.
Option is the right choice when absence is expected and normal — a database record that might not exist, a dictionary key that might be missing, a user preference that might not be set. In these cases, the absence is not an error; it is a valid outcome.
For cases where absence is caused by a failure you want to explain, use Result instead.
from explicit_result import Some, Nothing, Option
# Wrapping a value
o: Option[int] = Some(42)
# SAFE — None becomes Nothing
o = Option.of(user.email) # Nothing if email is None
# FOOTGUN — None becomes Some(None)
o = Some(user.email) # Some(None) — is_some() returns True!
# Representing absence
o: Option[int] = Nothing
# In a function
def get_config_value(key: str) -> Option[str]:
env = {"HOST": "localhost", "PORT": "8080"}
return Some(env[key]) if key in env else NothingSome(1).is_some() # True
Some(1).is_nothing() # False
Nothing.is_some() # False
Nothing.is_nothing() # True
# With a predicate
Some(4).is_some_and(lambda x: x > 3) # True
Some(2).is_some_and(lambda x: x > 3) # False
Nothing.is_some_and(lambda x: True) # False — predicate never calledThe same family of methods as Result, adapted for Option:
Some(42).unwrap() # 42
Nothing.unwrap() # raises UnwrapError
Some(1).unwrap_or(99) # 1
Nothing.unwrap_or(99) # 99
Some(1).unwrap_or_else(lambda: expensive_default()) # 1 (function not called)
Nothing.unwrap_or_else(lambda: expensive_default()) # result of function
Nothing.unwrap_or_raise(KeyError("config key missing"))
Some(1).expect("user session must exist") # 1
Nothing.expect("user session must exist") # raises UnwrapError with your message.map(f) — Applies f to the Some value. Nothing passes through unchanged.
Some("hello").map(str.upper) # Some("HELLO")
Nothing.map(str.upper) # Nothing.map_or(default, f) and .map_or_else(default_f, f) — Identical in behaviour to the Result equivalents.
.filter(predicate) — Returns the Some value if the predicate is True, otherwise Nothing.
Some(10).filter(lambda x: x > 5) # Some(10)
Some(3).filter(lambda x: x > 5) # Nothing
Nothing.filter(lambda x: True) # Nothing.and_then(f) — If Some, calls f with the value and returns the result (which must be an Option). If Nothing, returns Nothing.
def lookup_email(user_id: int) -> Option[str]:
emails = {1: "archy@example.com"}
return Some(emails[user_id]) if user_id in emails else Nothing
Some(1).and_then(lookup_email) # Some("archy@example.com")
Some(99).and_then(lookup_email) # Nothing
Nothing.and_then(lookup_email) # Nothing — f never called.or_else(f) — If Nothing, calls f and returns the result. If Some, returns self.
Nothing.or_else(lambda: Some("default")) # Some("default")
Some(1).or_else(lambda: Some(99)) # Some(1).zip(other) — Combines two Some values into a Some tuple. If either is Nothing, returns Nothing.
Some(1).zip(Some("a")) # Some((1, "a"))
Some(1).zip(Nothing) # Nothing
Nothing.zip(Some("a")) # Nothing.flatten() — Flattens Option[Option[T]] into Option[T].
Some(Some(42)).flatten() # Some(42)
Some(Nothing).flatten() # Nothing
Nothing.flatten() # Nothing.and_(other) and .or_(other) — Identical in pattern to the Result equivalents.
.ok_or(error) — Converts to Result. Some(v) becomes Ok(v), Nothing becomes Err(error).
Some(42).ok_or("not found") # Ok(42)
Nothing.ok_or("not found") # Err("not found").ok_or_else(error_f) — Like .ok_or but the error is computed lazily.
Nothing.ok_or_else(lambda: compute_error_context())Nothing is a singleton. There is only one Nothing in memory, regardless of how many times you use it. This means is comparisons work correctly:
result = find_user(99)
if result is Nothing:
print("user not found")It also means Nothing == Nothing is always True, and Nothing is Nothing is always True.
Unlike some other libraries, explicit-result's do-notation fully supports branching logic (if/else), loops, and early returns, as it leverages standard Python generators.
Important
Safety Guard (v0.3.1): If you use yield inside a function but forget the @do or @do_option decorator, explicit-result will issue a RuntimeWarning at runtime to prevent you from accidentally returning a silent generator object.
Yield a Result to unwrap its value. If it's an Err, the generator immediately short-circuits and returns that Err.
# TYPE CHECKER NOTE:
# Always annotate the return type explicitly — pyright and mypy cannot
# infer it from the generator body.
#
# WORKS:
@do()
def get_profile() -> Result[dict, str]: # ← required
user = yield fetch_user()
return user
# BREAKS type inference (checker sees Generator[...], not Result):
@do()
def get_profile(): # ← no annotation = no narrowing inside body
user = yield fetch_user()
return userfrom explicit_result import do, Ok, Err, Result
def fetch_user() -> Result[dict, str]: ...
def fetch_profile(uid: int) -> Result[dict, str]: ...
@do()
def get_user_profile() -> Result[dict, str]:
user = yield fetch_user() # Returns dict if Ok, short-circuits if Err
profile = yield fetch_profile(user["id"])
return {**user, **profile} # Returns Ok({ ... }) automaticallyThe same syntax works for Option using @do_option(). Yielding Nothing immediately returns Nothing.
# TYPE CHECKER NOTE:
# Always annotate the return type explicitly — pyright and mypy cannot
# infer it from the generator body.
#
# WORKS:
@do_option()
def get_profile() -> Option[dict]: # ← required
user = yield fetch_user()
return user
# BREAKS type inference (checker sees Generator[...], not Option):
@do_option()
def get_profile(): # ← no annotation = no narrowing inside body
user = yield fetch_user()
return userfrom explicit_result import do_option, Some, Nothing, Option
@do_option()
def get_leader_email(user_id: int) -> Option[str]:
user = yield get_user(user_id)
dept = yield get_dept(user["dept_id"])
leader = yield get_leader(dept["lead_id"])
return leader["email"] # Returns Some(...) automaticallyWhen an error propagates up the call stack, you often want to add context to explain where the error happened, without losing the original root cause. explicit_result provides .context() and .with_context().
from explicit_result import Ok, Err, Result
def read_file(path: str) -> Result[str, str]:
return Err("Permission denied")
def load_config() -> Result[str, Exception]:
return read_file("/etc/config.json").context("Failed to load configuration")
result = load_config()
# Err(ContextError("Failed to load configuration"))Under the hood, ContextError wraps the original error using Python's __cause__ mechanism. If you raise result.unwrap_err(), Python will print a standard traceback showing:
ContextError: Failed to load configuration
The above exception was the direct cause of the following exception:
...
You can also use .with_context(lambda e: ...) if the context message is expensive to compute, as it will only execute if the Result is Err.
Type note: .context() changes the error type from E to ContextError.
If you need to access the original error downstream, you can use the .root_cause() helper on the Result (returns an Option) or the .root_cause property on the ContextError.
orig = result.root_cause().unwrap_or("no error")
try: result.unwrap() except ContextError as e: print(f"Failed at: {e.message}") print(f"Original cause: {e.root_cause}")
---
## Diagnostic Visibility
explicit-result v0.3.1 introduces "Hybrid Representation" for errors. You get immediate diagnostic depth without any boilerplate.
### 1. The Verbose Default (`print`)
When you `print(result)` or convert it to a string, explicit-result extracts the **full stack trace** from where the error originated.
```python
# Output if result is Err:
Err(ValueError: invalid input)
Traceback (most recent call last):
File "logic.py", line 42, in safe_func
return 1 / 0
ZeroDivisionError: division by zero
When viewing results in a debugger, list, or log, you get a compact summary with the exact file and line.
# Output of repr(result):
Err(ZeroDivisionError: division by zero at logic.py:42)You can control traceback verbosity via the EXPLICIT_RESULT_VERBOSE_ERROR environment variable:
1(Default): Enable full tracebacks instr().0: Disable tracebacks (Concise mode only).
The @safe decorator wraps a function that might raise exceptions and converts it into a Result-returning function. It is the bridge between the existing Python ecosystem (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)
parse_int("42") # Ok(42)
parse_int("abc") # Err(ValueError("invalid literal for int() with base 10: 'abc'"))The decorated function now returns Result[int, ValueError] instead of int. The exception is captured and placed in the Err variant. Exceptions not listed in catch are re-raised normally — they are bugs and should not be silently swallowed.
Catching multiple exception types:
@safe(catch=(ValueError, KeyError))
def lookup_and_parse(data: dict, key: str) -> int:
return int(data[key])Preserving metadata: @safe uses functools.wraps, so the original function's __name__, __doc__, and __module__ are preserved.
The async equivalent of @safe. Wraps an async def function.
from explicit_result import safe_async
@safe_async(catch=ConnectionError)
async def fetch_data(url: str) -> bytes:
async with aiohttp.ClientSession() as session:
async with session.get(url) as response:
return await response.read()
# Returns Awaitable[Result[bytes, ConnectionError]]
result = await fetch_data("https://api.example.com/data")
match result:
case Ok(data):
process(data)
case Err(error):
log.warning(f"Fetch failed: {error}")@safe enforces strict rules about which exceptions it will accept. These rules exist to prevent silent masking of program-termination signals and critical bugs.
Forbidden — raises SafeDecoratorError at decoration time:
| Exception | Reason |
|---|---|
KeyboardInterrupt |
The user pressed Ctrl+C. Catching this would prevent clean shutdown. |
SystemExit |
Something called sys.exit(). Catching this defeats the purpose. |
GeneratorExit |
Generator cleanup signal. Must propagate for async to work correctly. |
@safe(catch=KeyboardInterrupt) # raises SafeDecoratorError immediately
def bad_function():
...Warning — catch=Exception emits a RuntimeWarning:
Catching Exception catches nearly everything, including AttributeError, IndexError, TypeError and other bugs. When you catch these and convert them to Err, you hide the bug from yourself.
@safe(catch=Exception) # works, but emits RuntimeWarning
def risky():
...If you have a genuine reason to catch all exceptions, acknowledge it explicitly:
@safe(catch=Exception, allow_broad=True) # no warning
def intentionally_broad():
...Mixing sync Result methods with async functions can feel clunky if you have to await inside .and_then() closures. explicit_result bridges this gap with its async helpers.
from_awaitable(awaitable) — Awaits an awaitable and wraps expected exceptions in Result.
map_async(result, async_func) — If Ok, awaits async_func(value) and wraps the result in Ok. If Err, returns the Err immediately.
and_then_async(result, async_func) — Like map_async, but async_func must return a Result.
import asyncio
from explicit_result import Ok, Err, Result, and_then_async
async def fetch_user(uid: int) -> Result[dict, str]: ...
async def fetch_posts(user: dict) -> Result[list, str]: ...
async def get_user_posts(uid: int) -> Result[list, str]:
user_res = await fetch_user(uid)
return await and_then_async(user_res, fetch_posts)Combinators are higher-order functions for working with collections of Result and Option values.
Turns an iterable of Result values into a single Result containing a list. Returns Ok([...]) if all results are Ok. Returns the first Err encountered and stops immediately.
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([])Use collect when you want to run multiple operations and either get all values or bail on the first failure.
Rule of thumb: collect stops at the first error; collect_all accumulates all errors.
Like collect, but gathers all errors instead of stopping at the first one. Returns Ok([...]) if all results are Ok, or Err([error1, error2, ...]) containing every error found.
from explicit_result import collect_all
collect_all([Ok(1), Err("a"), Ok(3), Err("b")])
# Err(["a", "b"])
collect_all([Ok(1), Ok(2), Ok(3)])
# Ok([1, 2, 3])Use collect_all for form validation, where you want to report every invalid field to the user in a single response rather than making them fix one error at a time.
Splits an iterable of Result values into two separate lists: one of Ok values and one of Err values.
from explicit_result import partition
oks, errs = partition([Ok(1), Err("a"), Ok(2), Err("b"), Ok(3)])
# oks = [1, 2, 3]
# errs = ["a", "b"]No values are lost. Every Result ends up in exactly one list.
Turns an iterable of Option values into a single Option containing a list. Returns Some([...]) if all options are Some. Returns Nothing if any option is Nothing.
from explicit_result import sequence
sequence([Some(1), Some(2), Some(3)]) # Some([1, 2, 3])
sequence([Some(1), Nothing, Some(3)]) # Nothing
sequence([]) # Some([])Converts Option[Result[T, E]] into Result[Option[T], E].
from explicit_result import transpose
transpose(Some(Ok(42))) # Ok(Some(42))
transpose(Some(Err("bad"))) # Err("bad")
transpose(Nothing) # Ok(Nothing)The inverse of transpose. Converts Result[Option[T], E] into Option[Result[T, E]].
from explicit_result import transpose_result
transpose_result(Ok(Some(42))) # Some(Ok(42))
transpose_result(Ok(Nothing)) # Nothing
transpose_result(Err("bad")) # Some(Err("bad"))Flattens a nested Result[Result[T, E], E] into Result[T, E].
from explicit_result import flatten_result
flatten_result(Ok(Ok(42))) # Ok(42)
flatten_result(Ok(Err("bad"))) # Err("bad")
flatten_result(Err("outer")) # Err("outer")from explicit_result import Ok, Err, Result, collect_all
import os
def require_env(key: str) -> Result[str, str]:
value = os.environ.get(key)
return Ok(value) if value is not None else Err(f"Missing required env var: {key}")
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 (1–65535)")
return Ok(port)
def load_config() -> Result[dict, list[str]]:
host_r = require_env("HOST")
port_r = require_env("PORT").and_then(parse_port)
db_r = require_env("DATABASE_URL")
errors = [r.unwrap_err() for r in [host_r, port_r, db_r] if r.is_err()]
if errors:
return Err(errors)
return Ok({
"host": host_r.unwrap(),
"port": port_r.unwrap(),
"db": db_r.unwrap(),
})
config = load_config()
match config:
case Ok(cfg):
start_server(cfg)
case Err(errors):
for error in errors:
print(f"Config error: {error}")
sys.exit(1)from explicit_result import Ok, Err, Result, collect_all
from dataclasses import dataclass
@dataclass
class UserForm:
username: str
email: str
age: str
def validate_username(s: str) -> Result[str, str]:
s = s.strip()
if len(s) < 3:
return Err("Username must be at least 3 characters")
if not s.isalnum():
return Err("Username must contain only letters and numbers")
return Ok(s)
def validate_email(s: str) -> Result[str, str]:
s = s.strip().lower()
if "@" not in s or "." not in s.split("@")[-1]:
return Err("Email address is not valid")
return Ok(s)
def validate_age(s: str) -> Result[int, str]:
try:
age = int(s)
except ValueError:
return Err("Age must be a whole number")
if not 13 <= age <= 120:
return Err("Age must be between 13 and 120")
return Ok(age)
def validate_form(form: UserForm) -> Result[dict, list[str]]:
results = [
validate_username(form.username),
validate_email(form.email),
validate_age(form.age),
]
combined = collect_all(results)
if combined.is_err():
return Err(combined.unwrap_err())
values = combined.unwrap()
return Ok({"username": values[0], "email": values[1], "age": values[2]})
# Usage
form = UserForm(username="a", email="notanemail", age="twelve")
result = validate_form(form)
if result.is_err():
errors = result.unwrap_err()
# ["Username must be at least 3 characters",
# "Email address is not valid",
# "Age must be a whole number"]from explicit_result import Ok, Err, Result, Option, Some, Nothing, safe
# Wrap the ORM call to convert exceptions into Results
@safe(catch=(DatabaseError, TimeoutError))
def _fetch_user_row(user_id: int) -> Row | None:
return db.execute("SELECT * FROM users WHERE id = ?", user_id).fetchone()
def get_user(user_id: int) -> Result[User, str]:
return (
_fetch_user_row(user_id)
.map_err(lambda e: f"Database error: {e}")
.and_then(lambda row:
Ok(User.from_row(row)) if row is not None
else Err(f"User {user_id} not found")
)
)
# At the call site — no try/except needed
def handle_profile_request(user_id: int) -> Response:
return get_user(user_id).map_or_else(
lambda error: Response({"error": error}, status=404),
lambda user: Response({"user": user.to_dict()}, status=200),
)When building REST APIs, you often need to unwrap a Result or Option and immediately raise an HTTP exception if it failed/is missing. explicit_result provides a non-intrusive integration for this:
from fastapi import FastAPI, HTTPException
from pydantic import BaseModel
from explicit_result import Result, Ok, Err
from explicit_result.integrations.fastapi import unwrap_or_http
app = FastAPI()
def get_user_from_db(user_id: int) -> Result[dict, str]:
if user_id != 1:
return Err("User not found in database")
return Ok({"id": 1, "name": "Archy"})
@app.get("/users/{user_id}")
async def get_user(user_id: int):
# If Ok, returns the user dict.
# If Err, automatically raises HTTPException(status_code=404, detail="User not found in database")
user = unwrap_or_http(get_user_from_db(user_id), status_code=404)
return userThe integration handles both Result and Option values seamlessly.
import asyncio
from explicit_result import Ok, Err, Result, safe_async
@safe_async(catch=(aiohttp.ClientError, asyncio.TimeoutError))
async def _http_get(url: str) -> aiohttp.ClientResponse:
async with aiohttp.ClientSession() as session:
response = await session.get(url, timeout=aiohttp.ClientTimeout(total=10))
response.raise_for_status()
return await response.json()
async def get_weather(city: str) -> Result[str, str]:
url = f"https://api.openweathermap.org/data/2.5/weather?q={city}"
return (
await _http_get(url)
).map(
lambda data: data["weather"][0]["description"]
).map_err(
lambda e: f"Could not fetch weather for {city!r}: {e}"
)
# Usage
async def main():
weather = await get_weather("Lagos")
match weather:
case Ok(description):
print(f"Lagos weather: {description}")
case Err(message):
print(f"Error: {message}")from explicit_result import Some, Nothing, Option
users = {1: {"name": "Archy", "dept_id": 10}}
depts = {10: {"name": "Engineering", "lead_id": 2}}
leaders = {2: {"name": "Chuks", "email": "chuks@company.com"}}
def get_user(uid: int) -> Option[dict]:
return Some(users[uid]) if uid in users else Nothing
def get_dept(did: int) -> Option[dict]:
return Some(depts[did]) if did in depts else Nothing
def get_leader(lid: int) -> Option[dict]:
return Some(leaders[lid]) if lid in leaders else Nothing
def get_dept_lead_email(user_id: int) -> Option[str]:
return (
get_user(user_id)
.and_then(lambda u: get_dept(u["dept_id"]))
.and_then(lambda d: get_leader(d["lead_id"]))
.map(lambda lead: lead["email"])
)
get_dept_lead_email(1) # Some("chuks@company.com")
get_dept_lead_email(99) # NothingWithout Option, this same logic requires nested if x is not None checks or a try/except block around dictionary accesses. With chaining, the happy path reads as a linear sequence.
Result is for recoverable, expected failures — things that are part of normal program operation.
- User input that doesn't match expected format
- A file that might not exist
- A network request that might time out
- A database record that might not be found
- A business rule that might not be satisfied (e.g., insufficient balance)
The key question: "Is this failure a valid state that my program should know how to respond to?" If yes, use Result.
Programming bugs. If code reaches an AttributeError because you called a method on None you assumed was not None, that is a bug in your code. It should crash loudly so you can find and fix it. Wrapping it in Err(...) hides the bug.
Program-termination signals. KeyboardInterrupt, SystemExit, GeneratorExit. The user or the runtime is telling the process to stop. Catching these in a Result would mean your program keeps running when it should have stopped.
Unrecoverable system failures. MemoryError, RecursionError. At this point your program may be in an undefined state. The right response is to let it crash, not to try to continue.
Syntax and import errors. These happen before your code runs. No runtime library can intercept them.
The table below summarises the boundary:
| Situation | Right tool |
|---|---|
| File not found | Result |
| Network timeout | Result |
| Invalid user input | Result |
| Business rule violation | Result |
| Database row not found | Result or Option |
AttributeError on assumed-not-None value |
Let it crash — it's a bug |
IndexError from out-of-range access |
Let it crash — it's a bug |
KeyboardInterrupt |
Let it propagate — it's intentional |
MemoryError |
Let it propagate — it's unrecoverable |
explicit_result ships a py.typed marker file (PEP 561), which tells mypy and pyright that this package provides its own type information. No plugins, no configuration, no extra installation required.
With mypy:
pip install mypy
mypy your_module.pyWith pyright:
pip install pyright
pyright your_module.pyBoth type checkers understand the generic type parameters and can infer the types through method chains:
r: Result[int, str] = Ok(42)
# mypy/pyright knows that x is int here
r.map(lambda x: x * 2) # inferred as Result[int, str]
# and that e is str here
r.map_err(lambda e: e.upper()) # inferred as Result[int, str]
# and that the chain result is Result[str, str]
r.map(str).map_err(str.upper)| Method | Signature | Description |
|---|---|---|
is_ok() |
() -> bool |
True if Ok |
is_err() |
() -> bool |
True if Err |
is_ok_and(f) |
(T -> bool) -> bool |
True if Ok and predicate passes |
is_err_and(f) |
(E -> bool) -> bool |
True if Err and predicate passes |
unwrap() |
() -> T |
Ok value or raises UnwrapError |
unwrap_or(d) |
(T) -> T |
Ok value or default |
unwrap_or_else(f) |
(E -> T) -> T |
Ok value or computed default |
unwrap_or_raise(exc) |
(Exception) -> T |
Ok value or raises given exception |
unwrap_err() |
() -> E |
Err value or raises UnwrapError |
expect(msg) |
(str) -> T |
Ok value or raises UnwrapError with message |
expect_err(msg) |
(str) -> E |
Err value or raises UnwrapError with message |
map(f) |
(T -> U) -> Result[U, E] |
Transform Ok value |
map_or(d, f) |
(U, T -> U) -> U |
Transform Ok or return default |
map_or_else(df, f) |
(E -> U, T -> U) -> U |
Transform Ok or compute from Err |
map_err(f) |
(E -> F) -> Result[T, F] |
Transform Err value |
and_then(f) |
(T -> Result[U, E]) -> Result[U, E] |
Chain on Ok (flatmap) |
or_else(f) |
(E -> Result[T, F]) -> Result[T, F] |
Recover from Err |
and_(other) |
(Result[U, E]) -> Result[U, E] |
Return other if Ok |
or_(other) |
(Result[T, F]) -> Result[T, F] |
Return self if Ok, other if Err |
ok() |
() -> Option[T] |
Convert to Option, dropping error |
err() |
() -> Option[E] |
Convert error to Option |
from_optional(v, e) |
(Optional[T], E) -> Result[T, E] |
Ok(v) if v is not None, else Err(e) |
| Method | Signature | Description |
|---|---|---|
is_some() |
() -> bool |
True if Some |
is_nothing() |
() -> bool |
True if Nothing |
is_some_and(f) |
(T -> bool) -> bool |
True if Some and predicate passes |
unwrap() |
() -> T |
Some value or raises UnwrapError |
unwrap_or(d) |
(T) -> T |
Some value or default |
unwrap_or_else(f) |
(() -> T) -> T |
Some value or computed default |
unwrap_or_raise(exc) |
(Exception) -> T |
Some value or raises given exception |
expect(msg) |
(str) -> T |
Some value or raises UnwrapError with message |
map(f) |
(T -> U) -> Option[U] |
Transform Some value |
map_or(d, f) |
(U, T -> U) -> U |
Transform Some or return default |
map_or_else(df, f) |
(() -> U, T -> U) -> U |
Transform Some or compute default |
filter(pred) |
(T -> bool) -> Option[T] |
Some if predicate passes, else Nothing |
and_then(f) |
(T -> Option[U]) -> Option[U] |
Chain on Some (flatmap) |
or_else(f) |
(() -> Option[T]) -> Option[T] |
Recover from Nothing |
and_(other) |
(Option[U]) -> Option[U] |
Return other if Some |
or_(other) |
(Option[T]) -> Option[T] |
Return self if Some, other if Nothing |
zip(other) |
(Option[U]) -> Option[(T, U)] |
Combine two Somes into a tuple |
flatten() |
() -> Option[T] |
Flatten Option[Option[T]] |
ok_or(e) |
(E) -> Result[T, E] |
Convert to Result with given error |
ok_or_else(f) |
(() -> E) -> Result[T, E] |
Convert to Result with computed error |
| Function | Signature | Description |
|---|---|---|
collect(results) |
Iterable[Result[T, E]] -> Result[List[T], E] |
All Ok or first Err |
collect_all(results) |
Iterable[Result[T, E]] -> Result[List[T], List[E]] |
All Ok or all Errs |
partition(results) |
Iterable[Result[T, E]] -> (List[T], List[E]) |
Split into ok values and errors |
sequence(options) |
Iterable[Option[T]] -> Option[List[T]] |
All Some or Nothing |
transpose(opt) |
Option[Result[T, E]] -> Result[Option[T], E] |
Flip Option/Result |
transpose_result(r) |
Result[Option[T], E] -> Option[Result[T, E]] |
Flip Result/Option |
flatten_result(r) |
Result[Result[T, E], E] -> Result[T, E] |
Flatten nested Result |
Can I use explicit_result alongside existing exception-based code?
Yes. The @safe and @safe_async decorators exist precisely for this purpose. You can wrap any exception-throwing function and convert it to a Result-returning one. You do not need to adopt explicit_result everywhere at once.
Does explicit_result work with async code?
Yes. Use @safe_async for async functions. Result and Option themselves are synchronous data types — they can hold Awaitable values like any other value.
What Python versions are supported?
Python 3.9 and later. Pattern matching (the match statement) requires Python 3.10+ but is not required to use the library — it is an optional convenience.
Is explicit_result thread-safe?
Ok, Err, Some, and Nothing are all immutable after construction. The Nothing singleton is created at import time. There are no shared mutable state issues.
Can I use Result values in sets or as dictionary keys?
Yes. Ok and Err both implement __hash__ and __eq__. However, like Python tuples, they are only hashable if their contained values are also hashable. For safety and predictability, it is strongly recommended to use immutable values inside Result and Option types.
How is this different from just checking if value is None?
Several ways. First, Option carries explicit type information — Option[str] is clearer than str | None. Second, absence and error are distinguished: Option for "might not exist," Result for "might fail with a reason." Third, the chaining methods (.and_then, .map, .or_else) let you compose optional lookups without nested if statements. Fourth, Nothing cannot be confused with a legitimate None value.
Why does @safe warn when I use catch=Exception?
Because Exception catches AttributeError, IndexError, TypeError, NameError, and dozens of other exceptions that indicate programming bugs, not expected failures. Catching them silently and converting them to Err(...) hides bugs from you. The warning is a reminder to be specific about what you expect to fail.
explicit-result provides native support for modern Python frameworks.
Use explicit_result.integrations.fastapi.unwrap_or_http to cleanly convert Result or Option values into HTTPException responses.
from explicit_result.integrations.fastapi import unwrap_or_http
@app.get("/users/{id}")
def read_user(id: int):
# Returns Ok(user) or Err("not_found")
result = user_service.find(id)
return unwrap_or_http(result, status_code=404)Option[T] and Result[T, E] types are compatible with Pydantic v2 models out of the box. They handle validation from JSON and serialize back to standard formats.
from pydantic import BaseModel
from explicit_result import Option, Result, Nothing
class UserProfile(BaseModel):
username: str
bio: Option[str] = Nothing # Validates from None -> Nothing
status: Result[str, str] # Validates from {"ok": "..."} or {"err": "..."}explicit-result is optimized for minimal overhead. In micro-benchmarks, it adds fixed overhead (~300ns) compared to raw Python features.
| Pattern | Native Python | explicit-result | Overhead |
|---|---|---|---|
| Happy Path (Ok vs Return) | ~95ns | ~400ns | +305ns |
| Error Path (@safe vs try) | ~600ns | ~900ns | +300ns |
Measured on Python 3.12.6 using pytest-benchmark.
Contributions are welcome. Please open an issue before submitting a pull request for significant changes.
When contributing:
- Add tests for any new behaviour
- Ensure the test suite passes:
python3 tests/run_tests.py - Follow the existing code style — type annotations on all public methods, docstrings with examples
MIT License. See LICENSE for details.