Python · Zero Dependencies · Fully Typed

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.

Zero dependencies Python 3.9+ mypy & pyright Pattern matching MIT License
$ pip install explicit-result

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).

Ok(value)

The success variant. Contains a value of type T. The operation completed without error.

Err(error)

The failure variant. Contains an error of type E. The error can be any type — string, enum, exception, dataclass.

Type params

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.

Some(value)

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
Nothing

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

01
Errors in the contract

A function that can fail should declare it in its return type. Callers must handle both cases to get the value.

02
Feels native to Python

Not a Haskell port. Not a Rust port. Every method name and design choice was made to feel natural to Python developers.

03
Adopt incrementally

Use Result in one module, exceptions in another. Wrap existing code with @safe. No big-bang rewrite required.

04
Zero dependencies

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]