Data Classes as Types made a value carry a guarantee. This chapter does the same for errors. Instead of raising an exception, a function returns its error as an ordinary value, and the type system tracks it.
Exceptions are Python’s default error mechanism, and they have costs. An exception unwinds the stack, so it discards any work done so far. It does not appear in the function’s return type, so the caller cannot see, from the signature, that the call might fail. And it is easy to forget to handle.
Returning the failure as a value reverses those costs. Failure appears in the return type, so the type checker reminds every caller to handle it, and a reviewer sees it without reading the body. Control flow stays local, with no exception leaping past intermediate frames to a distant handler. You do pay by handling the failure at each step, but that is the same discipline that stops an unhandled error from escaping unnoticed.
This material comes from my PyCon 2024 talk, Functional Error Handling.
If a function raises an exception partway through a comprehension, you lose all partial calculations. Any successful results computed before the failure vanish:
# exceptions_lose_data.py
def func_a(i: int) -> int:
print(f"Calculating func_a({i})")
if i == 3:
raise ValueError(f"func_a({i})")
return i
try:
results = [func_a(i) for i in range(5)]
print(results)
except ValueError as e:
print(f"Lost everything: {e}")
#: Calculating func_a(0)
#: Calculating func_a(1)
#: Calculating func_a(2)
#: Calculating func_a(3)
#: Lost everything: func_a(3)Function calls 0-2 produced correct values, but the exception
threw away the whole list. The only way to keep the good results
is to wrap each call in its own try, which is the
kind of scattering Data
Classes as Types flags as a problem.
The alternative is to return the error. The function’s return type becomes a union of the answer type and the error type. A union like this is a sum type: a value that is one thing or another. Nothing disappears, because the error is just another return value:
# sum_type.py
def func_a(i: int) -> int | str:
if i == 3:
return f"func_a({i})" # The error, returned as a value
return i
outputs = [func_a(i) for i in range(5)]
print(outputs)
#: [0, 1, 2, 'func_a(3)', 4]
for r in outputs:
match r:
case int(answer):
print(f"answer = {answer}")
case str(error):
print(f"error = {error!r}")
#: answer = 0
#: answer = 1
#: answer = 2
#: error = 'func_a(3)'
#: answer = 4This keeps every result, and match (see Pattern
Matching) tells the two cases apart. But the distinction
rides on the types int and str, which
is fragile. If a successful answer were also a string, the two
cases collide. We need something that says “success” or
“failure” no matter what types they carry.
Make success and failure explicit by defining them as types.
Success wraps an answer, Failure wraps
an error, and Result is the union of the two. Both
are frozen data classes, parameterized over the answer type and
the error type. A, B, and
E are type parameters (introduced in Static
Typing): placeholders that take concrete types when you use
the class. Here they have no bounds or constraints, so any type
can fill them. Result is useful beyond this
chapter, so it lives in utils/ and any chapter can
import it:
# utils/result.py
from collections.abc import Callable
from dataclasses import dataclass
@dataclass(frozen=True)
class Success[A]:
answer: A
def unwrap(self) -> A:
return self.answer
def bind[B, E](
self, func: Callable[[A], Result[B, E]]
) -> Result[B, E]:
return func(self.answer)
@dataclass(frozen=True)
class Failure[E]:
error: E
def bind[B](
self, func: Callable[..., Result[B, E]]
) -> Failure[E]:
return self # Pass the failure forward unchanged
type Result[A, E] = Success[A] | Failure[E]Ignore bind() for the moment. The two data
classes and the Result alias are enough to report
errors. A function that might fail returns a
Result. The signature tells the story:
# returning_result.py
from result import Failure, Result, Success
def func_a(i: int) -> Result[int, str]:
if i == 1:
return Failure(f"func_a({i})")
return Success(i)
if __name__ == "__main__":
for i in range(5):
print(i, func_a(i))
#: 0 Success(answer=0)
#: 1 Failure(error='func_a(1)')
#: 2 Success(answer=2)
#: 3 Success(answer=3)
#: 4 Success(answer=4)A function reports failure by returning a
Failure object, success by returning a
Success object.
Result[int, str] says this function returns an
int on success or a str on failure.
The caller cannot pretend the function returns an ordinary
value. To get the answer, the caller must unpack the
Result. This is the same idea as in Static Typing: put
the meaning in the type. Python’s humbler spelling of the same
idea is int | None, and the comparison locates
Result’s value. Both force the caller to unpack,
but None says only “no answer,” while a
Failure carries why. Use
| None when absence is the whole story, a lookup
that found nothing. Use Result when the caller may
need to act on the reason, or when several different failures
must stay distinguishable, as Matching on the Error shows
below.
A function like this is a Total Function: its return
type accounts for every outcome it can produce, success or
failure, with nothing left for an exception to sneak out
through. Raise an exception instead, and the signature no longer
tells the truth. A caller can’t see the failure just by reading
the return type. Python does not enforce totality. Nothing stops
a Result-returning function from also raising an
exception, so this is a discipline the author of the function
maintains, not a guarantee the checker provides.
Because failures are values, you can assert on them directly,
with no pytest.raises(). The tests check
unwrap(), and that bind() chains a
success and short-circuits a failure:
# test_result.py
from result import Failure, Success
def test_success_unwrap() -> None:
assert Success(5).unwrap() == 5
def test_bind_chains_a_success() -> None:
assert Success(1).bind(lambda x: Success(x + 1)) == Success(2)
def test_bind_short_circuits_a_failure() -> None:
failure: Failure[str] = Failure("boom")
assert failure.bind(lambda x: Success(x + 1)) is failureReal programs chain steps. With a Result, each
step can fail, so you must check each call before the next one
runs. You can catch an exception from existing code and turn it
into a Failure, so the failure becomes data rather
than control flow:
# composing.py
# Composing functions that return Results, by hand.
from result import Failure, Result, Success
from returning_result import func_a
def func_b(i: int) -> Result[int, str]:
if i == 2:
return Failure(f"func_b({i})")
return Success(i)
def func_c(i: int) -> Result[int, str]:
try:
1 / (i - 3) # A probe: raises an exception when i == 3
except ZeroDivisionError as e:
# The exception becomes a value:
return Failure(f"func_c({i}): {e}")
return Success(i)
def composed(i: int) -> Result[int, str]:
a = func_a(i)
if isinstance(a, Failure):
return a
b = func_b(a.unwrap())
if isinstance(b, Failure):
return b
return func_c(b.unwrap())
if __name__ == "__main__":
for i in range(5):
print(i, composed(i))
#: 0 Success(answer=0)
#: 1 Failure(error='func_a(1)')
#: 2 Failure(error='func_b(2)')
#: 3 Failure(error='func_c(3): division by zero')
#: 4 Success(answer=4)Each step returns early when it encounters a
Failure. This works, and it keeps errors as values,
but every step is the same dance: call, check for
Failure, return early, unwrap, go on.
bind() captures the dance. Look again at the
bind() method on Result. On a
Success, it feeds the answer to the next function.
On a Failure, it ignores the function and returns
the failure unchanged. A Failure anywhere in a
chain skips the rest of the steps and falls through to the
end:
# composing_with_bind.py
from composing import func_b, func_c
from result import Result
from returning_result import func_a
def composed(i: int) -> Result[int, str]:
return func_a(i).bind(func_b).bind(func_c)
if __name__ == "__main__":
for i in range(5):
print(i, composed(i))
#: 0 Success(answer=0)
#: 1 Failure(error='func_a(1)')
#: 2 Failure(error='func_b(2)')
#: 3 Failure(error='func_c(3): division by zero')
#: 4 Success(answer=4)The body is now one line that reads in order:
func_a(), then func_b(), then
func_c(). Bind removes the boilerplate by chaining
the steps. The error checking has not gone away. It moved into
bind(), where it appears once. A
Failure anywhere short-circuits the whole
thing.
A type that carries a value plus this chaining operation is what functional programmers call a monad. You do not need to know that word to use functional error handling.
One near-miss to expect when you start chaining:
bind() requires each step to return a
Result. Feed it a plain function,
.bind(str) say, and the chain now holds a bare
str where a Result belongs, which the
checker flags at the next bind(). To chain a plain
function, wrap its return value:
.bind(lambda x: Success(str(x))). Libraries like
returns name that pattern map(), a
sibling of bind() for steps that cannot fail, and
exercise 2’s map_error() is the same idea aimed at
the error side.
Testing confirms that the hand-written and
bind() versions agree on every input:
# test_composing.py
from composing import composed as composed_manual
from composing_with_bind import composed as composed_bind
def test_manual_and_bind_agree() -> None:
for i in range(5):
assert composed_manual(i) == composed_bind(i)bind() threads one value through a chain. When
you have several independent inputs, nest the binds so each
answer stays in scope for the next step:
# combining.py
from composing import func_b, func_c
from result import Result, Success
from returning_result import func_a
def add(a: int, b: int, c: int) -> str:
return f"add({a} + {b} + {c}): {a + b + c}"
def combined(i: int, j: int) -> Result[str, str]:
return func_a(i).bind(
lambda a: func_b(j).bind(
lambda b: func_c(i + j).bind(
lambda c: Success(add(a, b, c)))))
if __name__ == "__main__":
for args in [(1, 5), (7, 2), (2, 1), (7, 5)]:
print(args, combined(*args))
#: (1, 5) Failure(error='func_a(1)')
#: (7, 2) Failure(error='func_b(2)')
#: (2, 1) Failure(error='func_c(3): division by zero')
#: (7, 5) Success(answer='add(7 + 5 + 12): 24')Nested binds carry each answer inward. A Failure
anywhere short-circuits to the end. Only the last input passes
all three steps, so it’s the only one that reaches
add().
Testing confirms combining returns the correct value, or the first failure in the chain:
# test_combining.py
import pytest
from combining import combined
from result import Failure, Result, Success
@pytest.mark.parametrize("a, b, expected", [
(7, 5, Success("add(7 + 5 + 12): 24")),
(1, 5, Failure("func_a(1)")),
(2, 1, Failure("func_c(3): division by zero")),
])
def test_combined(
a: int, b: int, expected: Result[str, str]
) -> None:
assert combined(a, b) == expectedIn composing.py, func_c() wrapped a
risky call in try/except and returned
a Failure by hand. A decorator can capture that
pattern. @safe takes a function that raises an
exception and gives back one that returns a Result,
with the exception as the Failure value. Like
result.py, it lives in utils/ and any
chapter can import it:
# utils/safe.py
from collections.abc import Callable
from functools import wraps
from result import Failure, Result, Success
def safe[**P, A](
func: Callable[P, A],
) -> Callable[P, Result[A, Exception]]:
@wraps(func)
def wrapper(
*args: P.args, **kwargs: P.kwargs
) -> Result[A, Exception]:
try:
return Success(func(*args, **kwargs))
except Exception as e:
return Failure(e)
return wrapper
@safe
def parse(text: str) -> int:
return int(text)
if __name__ == "__main__":
for text in ("42", "oops"):
match parse(text):
case Success(answer):
print(f"{text}: parsed {answer}")
case Failure(error):
print(f"{text}: {type(error).__name__}")
#: 42: parsed 42
#: oops: ValueErrorparse() still reads like a normal function that
returns an int, but @safe has changed
its type to Result[int, Exception]. The caller
cannot ignore the failure, because it must unpack the
Result to reach the number. The **P
parameter carries the wrapped function’s whole parameter list
through, the technique from Decorators,
so parse("42") type-checks and
parse(42) does not: @safe changes only
the return type, never what the function accepts.
The Decorators chapter
explains how to write decorators like @safe,
including functools.wraps.
To test @safe, a good input becomes a
Success, and a raised exception becomes a
Failure holding that exception:
# test_safe.py
from result import Failure, Success
from safe import safe
@safe
def parse(text: str) -> int:
return int(text)
def test_safe_wraps_a_success() -> None:
assert parse("42") == Success(42)
def test_safe_captures_the_exception() -> None:
match parse("oops"):
case Failure(error):
assert isinstance(error, ValueError)
case _:
raise AssertionError("expected a Failure")Because the error is a value, and is often an exception, you
can pattern-match the Result and the exception type
together. Each kind of failure gets its own branch:
# matching_errors.py
from result import Failure, Result, Success
from safe import safe
@safe
def parse(text: str) -> int:
return int(text)
@safe
def reciprocal(n: int) -> float:
return 1 / n
def describe(text: str) -> str:
result: Result[float, Exception] = parse(text).bind(reciprocal)
match result:
case Success(answer):
return f"{text}: {answer}"
case Failure(ValueError()):
return f"{text}: Not a number"
case Failure(ZeroDivisionError()):
return f"{text}: Cannot divide by zero"
case Failure(error):
return f"{text}: {type(error).__name__}"
if __name__ == "__main__":
for text in ("4", "0", "OOPS"):
print(describe(text))
#: 4: 0.25
#: 0: Cannot divide by zero
#: OOPS: Not a numberparse() and reciprocal() are both
wrapped with @safe, so bind() chains
them. A ValueError from a bad number and a
ZeroDivisionError from dividing by zero arrive as
ordinary Failure values, and the match
tells them apart.
You need not build Result yourself. The returns library
provides a Result type with Success
and Failure, the same @safe decorator
we just built, and do-notation that makes combining multiple
results read more directly than nested binds.
This style does not replace exceptions everywhere. Exceptions are still appropriate for truly exceptional conditions, the ones no caller can reasonably handle, such as running out of memory or a programming bug. Some languages call these errors “panics” and separate them from regular exceptions.
Use a Result for the failures that are part of a
function’s normal job: bad input, a missing file, a value out of
range. Those are not exceptional. They are expected, and the
type should say so.
func_e() that returns a
Result[int, str], and extend the
bind() chain in composing_with_bind.py
to include it. Confirm a Failure from
func_e() still short-circuits.Failure a map_error() method
that transforms the error it holds, leaving a
Success untouched (for chains to keep working,
Success needs its own map_error() that
returns self). Use it to add a prefix to every
error.combined so it collects all the
failures instead of stopping at the first one, returning
Result[str, list[str]]. Write the tests first.