Python Error Handling Considered Unpythonic
  • Python
  • Functional Programming

Python Error Handling Considered Unpythonic

Preface

Python encourages us to write code that is explicit, readable, and simple. Exceptions are part of its everyday idiom, yet a function's signature rarely tells us which failures it may raise. When invalid input is an expected part of the job, that leaves an important part of the function's behavior outside its declared type.

This article argues that treating expected failures as values can bring error handling closer to the Zen of Python. Exceptions have practical strengths, especially when a failure must pass through several layers before it can be handled. The question is whether routine failures always need that separate control-flow mechanism.

We'll build a small Result type, use fmap and bind to compose operations, and explore a hypothetical do syntax for keeping those operations readable. The examples are teaching tools, with their limitations made explicit. The generic function and method syntax used later requires Python 3.12 or newer.

The Zen of Python

Python is unusually willing to state its philosophy. Type import this into an interpreter, and it prints a small manifesto: the Zen of Python.

The Zen of Python, by Tim Peters

Beautiful is better than ugly.
Explicit is better than implicit.
Simple is better than complex.
Complex is better than complicated.
Flat is better than nested.
Sparse is better than dense.
Readability counts.
Special cases aren't special enough to break the rules.
Although practicality beats purity.
Errors should never pass silently.
Unless explicitly silenced.
In the face of ambiguity, refuse the temptation to guess.
There should be one-- and preferably only one --obvious way to do it.
Although that way may not be obvious at first unless you're Dutch.
Now is better than never.
Although never is often better than *right* now.
If the implementation is hard to explain, it's a bad idea.
If the implementation is easy to explain, it may be a good idea.
Namespaces are one honking great idea -- let's do more of those!

What Makes Code Pythonic?

The Zen gives us a basis for judging whether code is Pythonic: does it favor readability, simplicity, and explicit intent? Code can be valid Python and produce the right result while still working against those values.

These principles require judgment. "Although practicality beats purity" reminds us to weigh tradeoffs in context. Calling code unpythonic should mean identifying a value it conflicts with and explaining why that conflict matters.

Let's see how these principles apply to code. The examples use two data classes:

from dataclasses import dataclass


@dataclass
class User:
    name: str
    email: str
    active: bool


@dataclass
class Order:
    is_paid: bool
    email: str | None

Simple Is Better Than Complex

To calculate the sum of a list of prices:

# Unpythonic
prices: list[int] = [10, 20, 30]
total: int = 0
index: int = 0
while index < len(prices):
    total += prices[index]
    index += 1

# Pythonic
prices: list[int] = [10, 20, 30]
total: int = sum(prices)

The built-in sum() states the operation directly. There is no index or running total to manage.

Readability Counts

To select the email addresses of active users, given users: list[User]:

# Unpythonic
result: list[str] = [u.email for u in users if u.active == True]

# Pythonic
active_emails: list[str] = [user.email for user in users if user.active]

Both versions select the same addresses. The second names the values and the result clearly, and its condition avoids a redundant comparison to True.

Flat Is Better Than Nested

Suppose we send a receipt only when an order is paid and has an email address. Assume the application provides create_receipt(order: Order) -> str and email_receipt(address: str, receipt: str) -> None:

# Unpythonic
def send_receipt(order: Order) -> None:
    if order.is_paid:
        if order.email:
            receipt: str = create_receipt(order)
            email_receipt(order.email, receipt)


# Pythonic
def send_receipt(order: Order) -> None:
    if not order.is_paid:
        return
    if not order.email:
        return

    receipt: str = create_receipt(order)
    email_receipt(order.email, receipt)

Early returns handle the cases where there is nothing to do, leaving the main operation at one indentation level. The benefit grows as more conditions are added.

Explicit Is Better Than Implicit

To calculate the area of a circle:

# Unpythonic
from math import *

radius: float = 5.0
area: float = pi * radius ** 2

# Pythonic
import math

radius: float = 5.0
area: float = math.pi * radius ** 2

math.pi tells the reader where the constant comes from. A wildcard import obscures that origin and can introduce names that collide with existing ones.

Special Cases Aren't Special Enough to Break the Rules

Suppose a function searches for users by name. Returning a single user when there is exactly one match might seem convenient, but it breaks the function's otherwise consistent return type:

# Unpythonic
def find_users(users: list[User], name: str) -> User | list[User]:
    matches: list[User] = [user for user in users if user.name == name]
    if len(matches) == 1:
        return matches[0]
    return matches


# Pythonic
def find_users(users: list[User], name: str) -> list[User]:
    return [user for user in users if user.name == name]

The first version makes callers check whether they received a user or a list. The second always returns a list, whether it contains zero, one, or many users. Finding one match does not change the function's contract, so callers can handle every outcome with the same loop.

What Makes Error Handling Unpythonic?

Exceptions are an established Python idiom, but familiarity alone does not settle how well they fit the Zen. For expected failures, three questions matter: what does the function make explicit, how easily can we follow its execution, and why does failure need a separate path?

Explicit Is Better Than Implicit

Consider a function that reads a port number from user input. For this application, we accept ports from 1 through 65535:

def parse_port(text: str) -> int:
    port: int = int(text)
    if not 1 <= port <= 65535:
        raise ValueError("Port must be between 1 and 65535")
    return port

The function can reject input in two places. int(text) raises ValueError if conversion fails; our range check raises it if the number is outside the accepted range. Both are ordinary possibilities when reading user input.

Yet the signature describes only the returned value. Nothing in -> int tells the caller which failures to expect. Python's return annotations do not declare exceptions, so functions with the same signature can fail in different ways.

The raise statement itself is explicit. What is missing is the failure contract at the function boundary. To discover it, callers must read the implementation or rely on documentation that may fall behind as the function changes.

Readability Counts

Now call parse_port through another function and catch the error outside both:

def describe_endpoint(text: str) -> str:
    port: int = parse_port(text)
    print("Port accepted")
    return f"Listening on port {port}"


try:
    endpoint: str = describe_endpoint("invalid")
    print(endpoint)
except ValueError as error:
    print(f"Invalid port: {error}")

print("Finished")

With "invalid" as input, parse_port raises ValueError. Execution leaves both functions and enters the except block, which prints the error. The program then prints "Finished". Neither function returns a value, and the lines that print "Port accepted" and the endpoint are skipped.

Following this behavior requires looking in three places: where the error starts, the function it passes through, and the handler. Reading describe_endpoint alone does not reveal which exceptions it may propagate. More layers can make that path harder to follow.

This also puts pressure on "Simple is better than complex." A call may return normally or raise an exception that skips the following code. Automatic propagation saves callers from forwarding failures manually, but it can move the explanation of that behavior away from the call.

Special Cases Aren't Special Enough to Break the Rules

A function normally communicates its outcome by returning a value. Exceptions give failure a special mechanism: abandon the return path and transfer control to a matching handler, potentially several calls away.

Does expected failure need that separate mechanism? Invalid input is a normal possibility for parse_port. We could return either a valid port or a description of the failure, with both outcomes named in the return type.

For example, we could return an integer on success and a PortError value on failure:

class PortError(Exception):
    pass


def parse_port(text: str) -> int | PortError:
    try:
        port: int = int(text)
    except ValueError:
        return PortError("Port must be an integer")

    if not 1 <= port <= 65535:
        return PortError("Port must be between 1 and 65535")
    return port


result: int | PortError = parse_port("invalid")
if isinstance(result, PortError):
    print(f"Invalid port: {result}")
else:
    print(f"Listening on port {result}")

PortError is an exception class, but returning an instance does not raise it. The annotation int | PortError names both expected outcomes, and the caller handles them with an ordinary if. The function still catches int()'s conversion exception internally, then returns the failure as data.

This approach introduces another problem: it can work against "Flat is better than nested." Suppose we also need a nonempty host name before we can describe the endpoint:

class HostError(Exception):
    pass


def parse_host(text: str) -> str | HostError:
    host: str = text.strip()
    if not host:
        return HostError("Host must not be empty")
    return host


port_result: int | PortError = parse_port("8080")
if isinstance(port_result, PortError):
    print(f"Invalid port: {port_result}")
else:
    host_result: str | HostError = parse_host("localhost")
    if isinstance(host_result, HostError):
        print(f"Invalid host: {host_result}")
    else:
        endpoint: str = f"{host_result}:{port_result}"
        print(f"Endpoint: {endpoint}")

Building the endpoint now sits inside two else blocks. Repeating this pattern for more operations pushes the main task deeper into error checks. The failures are explicit, but we still need a way to combine operations without this nesting.

The consistency argument remains: if success can be data, expected failure can be data too. A separate mechanism needs a practical justification, especially when the caller must inspect and respond to the failure immediately.

The Zen offers a defense: "Although practicality beats purity." Exceptions can cross layers that have no useful recovery to perform, without manual forwarding at each layer. "Errors should never pass silently" also matters: an unhandled exception makes failure visible, while a returned error can be ignored. Returning errors does not automatically solve every error-handling problem.

For routine failures, the question is whether that convenience outweighs the missing information in the signature. Java offers one way to make that information explicit while keeping exceptions.

Are Checked Exceptions Better?

Java addresses part of this problem with checked exceptions. A method must declare the checked exceptions it can let escape using throws. A caller must then catch those exceptions or declare that it can pass them on. If it does neither, the type checker rejects the code. This rule does not apply to unchecked exceptions, including RuntimeException, Error, and their subclasses. Java Language Specification

Here is a Java version of our port parser:

class PortException extends Exception {
    PortException(String message) {
        super(message);
    }
}

class PortParser {
    static int parsePort(String text) throws PortException {
        int port;
        try {
            port = Integer.parseInt(text);
        } catch (NumberFormatException error) {
            throw new PortException("Port must be an integer");
        }

        if (port < 1 || port > 65535) {
            throw new PortException("Port must be between 1 and 65535");
        }
        return port;
    }
}

The important part is throws PortException. We can see the expected failure without reading the method's body. A method that calls parsePort must either handle PortException with try/catch or include it in its own throws declaration. It cannot simply leave this checked exception unaccounted for.

For explicitness, this improves on Python's exception handling. Java's type checker enforces a part of the contract that Python leaves to documentation and the reader.

But it still gives failure a separate path. Success returns an integer; failure throws an exception and transfers control to a handler. Declaring that path makes it more visible, but it does not answer our question: why should an expected failure need a special control-flow mechanism?

Returning errors as values goes a step further by using the same path for both outcomes. Our first attempt made failures visible in the type, but left us with repeated checks and nesting. We'll now introduce a result type, implement it, and build operations that make those checks reusable.

A Function That Might Fail

Our original parser had the type str → int. To describe expected failure too, its output needs to carry either a port or an error.

Consider a type named Result[E, T]. Here, E is the type of the error, and T is the type of the successful value. A result holds one of two possibilities: Result.Success(value) for success or Result.Failure(error) for failure. Result is not a Python built-in; for now, we'll focus on how to use it and leave its implementation for later.

Using this type, our parser would look like this:

def parse_port(text: str) -> Result[PortError, int]:
    try:
        port: int = int(text)
    except ValueError:
        return Result.Failure(PortError("Port must be an integer"))

    if not 1 <= port <= 65535:
        return Result.Failure(PortError("Port must be between 1 and 65535"))

    return Result.Success(port)

Read the signature as: "Give me a string, and I'll return either a successful integer or a port error." For example, parse_port("8080") returns Result.Success(8080), while parse_port("invalid") returns Result.Failure(PortError("Port must be an integer")).

The function's type is now a string-to-result function: str → Result[PortError, int]. Expected failure is part of its output, alongside success. Both outcomes use return, so neither needs a separate path through a raised exception to reach the caller.

Unlike a plain int | PortError, Result labels success and failure separately, even when their payload types happen to be the same. It also gives us a place to define reusable operations for combining results. The annotation describes expected outcomes; it neither prevents unexpected exceptions nor forces callers to handle a returned error.

Let's Implement the Result Type

Here is our implementation for storing and inspecting successful values and errors:

from typing import Generic, TypeVar

T_co = TypeVar("T_co", covariant=True)
E_co = TypeVar("E_co", bound=Exception, covariant=True)


class Result(Generic[E_co, T_co]):
    @classmethod
    def Success[U](cls, value: U) -> "Result[E_co, U]":
        return Result[E_co, U](value, None)

    @classmethod
    def Failure[Err: Exception](cls, error: Err) -> "Result[Err, T_co]":
        return Result(None, error)

    def __init__(self, value: T_co | None, error: E_co | None) -> None:
        self._value = value
        self._error = error

        if (value is None) and (error is None):
            raise ValueError("Result must have either a value or an error")

        if (value is not None) and (error is not None):
            raise ValueError("Result cannot have both a value and an error")

    @property
    def error(self) -> E_co:
        if self._error is None:
            raise ValueError("Result has no error")

        return self._error

    def is_success(self) -> bool:
        return self._value is not None

    def is_failure(self) -> bool:
        return self._error is not None

    def unwrap(self) -> T_co:
        if self._value is not None:
            return self._value

        assert self._error is not None
        raise ValueError("Cannot unwrap a Result with an error") from self._error

The Type Parameters

TypeVar declares a type parameter, and Generic[E_co, T_co] makes Result accept two types: an error type and a successful value's type. E_co is bounded by Exception, so it must be Exception or one of its subclasses. T_co has no such restriction. Our port parser returns Result[PortError, int].

Both parameters use covariant=True. This lets a result with more specific types be used where broader types are expected. For example, a Result[PortError, int] can be used where a Result[Exception, object] is expected: a PortError is an Exception, and an integer is an object. The _co suffix is a naming convention that reminds us of this covariance. Python typing specification

The stored fields are internal by convention, not enforced privacy. Callers should treat results as read-only and use the public methods to inspect them. The covariance annotation relies on that discipline; Python does not prevent someone from modifying the fields directly.

Creating Successes and Failures

Success and Failure are class methods, so we can call them directly on Result. Python passes the class as cls, although these implementations explicitly construct Result rather than using cls.

Each factory introduces a type parameter for the input it accepts. Success[U] takes a value of type U and returns Result[E_co, U], keeping the class's error type. Failure[Err: Exception] takes an exception of type Err and returns Result[Err, T_co], keeping the class's successful value type.

These separate parameters let the factories describe new inputs without using the covariant class parameters directly as their input types.

success: Result[PortError, int] = Result.Success(8080)
failure: Result[PortError, int] = Result.Failure(
    PortError("Port must be an integer")
)

Success stores the value alongside an absent error; Failure stores the error alongside an absent value. Neither factory raises the stored error. The quoted return annotations name Result before its class definition is complete.

Keeping the State Valid

The constructor stores _value and _error, then checks that exactly one is non-None. Both missing would mean no outcome; both present would mean conflicting outcomes. Either mistake raises ValueError.

This implementation uses None to mean absent, so Result.Success(None) is rejected too. Other false-like values, such as 0, False, and an empty string, are valid successes because the checks use is None, rather than truthiness.

Checking and Reading the Outcome

is_success() checks whether a value is present. is_failure() checks whether an error is present. For a valid result, exactly one returns True.

unwrap() returns the successful value, while the error property returns the stored error. The property is accessed as result.error, without parentheses. Both accessors check that the requested outcome exists:

result: Result[PortError, int] = parse_port("8080")
if result.is_success():
    port: int = result.unwrap()
    print(f"Port accepted: {port}")
else:
    error: PortError = result.error
    print(f"Invalid port: {error}")

These boolean checks do not automatically narrow the result's type for the type checker. Instead, the accessors provide the annotated value or error and check at runtime that it is available.

Using Raise as a Panic Mechanism

Invalid user input is an expected failure, so parse_port returns Result.Failure(PortError(...)). Constructing an invalid result, reading error from a success, or calling unwrap() on a failure means the code is using Result incorrectly.

For those mistakes, we use raise as a panic mechanism: stop the current operation and make the misuse visible. These raises occur in a controlled part of the implementation, at deliberate checks inside Result. They remain ordinary Python exceptions that can propagate or be caught; they do not guarantee program termination.

On a failed result, unwrap() first asserts that the error is present. It then uses raise ValueError(...) from self._error to keep the original error as the new exception's cause. The traceback can therefore show both the failed unwrap and the failure that led to it.

The Nesting Problem Still Exists

Result makes a function's expected failures visible in its return type, but we still have to check each result before using its value. When several steps can fail, those checks can become nested.

Suppose we want to build an endpoint from a port and a host name. Alongside parse_port, we need a function that checks whether the host is empty:

class HostError(Exception):
    pass


def parse_host(text: str) -> Result[HostError, str]:
    host: str = text.strip()
    if not host:
        return Result.Failure(HostError("Host must not be empty"))
    return Result.Success(host)

We can combine the two parsers by checking for success at each step:

def describe_endpoint(
    port_text: str, host_text: str
) -> Result[PortError | HostError, str]:
    port_result: Result[PortError, int] = parse_port(port_text)
    if port_result.is_success():
        port: int = port_result.unwrap()

        host_result: Result[HostError, str] = parse_host(host_text)
        if host_result.is_success():
            host: str = host_result.unwrap()
            return Result.Success(f"{host}:{port}")
        else:
            return Result.Failure(host_result.error)
    else:
        return Result.Failure(port_result.error)

For describe_endpoint("8080", "localhost"), both checks succeed and the function returns Result.Success("localhost:8080"). Building the endpoint sits inside two if blocks.

For describe_endpoint("invalid", "localhost"), the first check fails. We return the port error without parsing the host. For describe_endpoint("8080", ""), parsing the port succeeds, but parsing the host fails, so we return the host error instead.

Each step repeats the same decision: use the value on success, or pass the error back on failure. Adding more steps with this pattern pushes the successful path deeper into nested blocks. The return types are explicit, but the code still works against "Flat is better than nested."

Early returns could reduce the indentation. The problem is not that returning errors requires nesting, but that the wrapper alone has not removed the repeated checks. To reuse those checks, we need to look at how these functions compose.

Composing Results

Changing the return type also changes how one function connects to the next.

Function composition means passing the output of one function into another. If f has the type a → b and g has the type b → c, we can write g(f(x)). The first function produces exactly the type the second function expects:

f: a → b
g: b → c

g(f(x)): c

Now change f to return an explicit result. Its type becomes a → Result[E, b]. Its output is no longer a b; it is a result that might contain a b or an error of type E. But g still expects a b:

f: a → Result[E, b]
g: b → c

g(f(x)): type mismatch: g expects b, but receives Result[E, b]

For a concrete example, consider a function that formats a parsed port:

def format_port(port: int) -> str:
    return f"Listening on port {port:d}"

When parse_port returned an int, we could call format_port(parse_port("8080")) directly. With its new return type, Result[PortError, int], that expression no longer type-checks. Even on success, Result.Success(8080) is a result object, not the integer 8080 that format_port needs.

To connect the functions, we must decide what to do with both possible outcomes. On success, extract the integer and pass it to format_port. On failure, skip formatting and carry the error forward. Calling unwrap() unconditionally would turn an expected parsing failure into a panic, so it does not solve the problem safely.

If g can fail too, with type b → Result[F, c], it still needs a b rather than a result object. The combined function should have type a → Result[E | F, c]: a successful c or an error from either step.

This is the decision repeated in the nested checks: pass a successful value to the next function, or carry the failure forward without calling it. We can give that decision a reusable form.

Transforming Values with fmap

Start with format_port, which transforms an integer into a string. It needs the value inside a successful result and should be skipped when parsing fails.

We can put that decision in a method named fmap. Add this method to Result, with Callable imported from collections.abc:

    def fmap[U](self, func: Callable[[T_co], U]) -> "Result[E_co, U]":
        if self.is_success():
            value = self.unwrap()
            return Result.Success(func(value))

        return Result.Failure(self.error)

Callable[[T_co], U] describes a function that takes the current value's type and returns a value of type U. On success, fmap extracts the value, calls the function, and wraps its output in Result.Success. On failure, it returns a failure containing the same error object without calling the function.

In terms of types, fmap connects Result[E, T] to a function T → U, producing Result[E, U]. The successful value's type can change, while the error type stays the same.

Our formatting example becomes:

message: Result[PortError, str] = parse_port("8080").fmap(format_port)
invalid_message: Result[PortError, str] = parse_port("invalid").fmap(format_port)

For "8080", parsing returns Result.Success(8080). fmap passes 8080 to format_port and returns Result.Success("Listening on port 8080"). For "invalid", parsing returns a failure. fmap skips formatting and carries the original PortError forward.

The success check now lives in one reusable method, instead of an if/else at each call site.

fmap does not catch exceptions raised by its callback. A callback that can fail in an expected way needs to represent that failure explicitly. Also, this implementation rejects None as a successful value, so a callback returning None causes Result.Success(None) to raise ValueError.

Connecting Fallible Functions with bind

What if the next function already returns a Result? Consider this helper, which uses our earlier parse_host function to build an endpoint for a given port:

def describe_host(port: int) -> Result[HostError, str]:
    return parse_host("localhost").fmap(lambda host: f"{host}:{port}")

The helper has the type int → Result[HostError, str]. We can pass it to fmap, but look at the resulting type:

nested: Result[PortError, Result[HostError, str]] = (
    parse_port("8080").fmap(describe_host)
)

describe_host returns Result.Success("localhost:8080"). Then fmap wraps that result in another success, giving us Result.Success(Result.Success("localhost:8080")). We now have two layers to inspect.

More importantly, if describe_host returned a failure, the outer result would still be a success containing that failure. The outer success would only tell us that parsing the port succeeded, rather than that the whole sequence succeeded. In general, passing T → Result[F, U] to fmap produces Result[E, Result[F, U]].

We need a method that returns the callback's result directly. That method is bind:

    def bind[Err: Exception, U](
        self, func: Callable[[T_co], "Result[Err, U]"]
    ) -> "Result[E_co | Err, U]":
        if self.is_success():
            value = self.unwrap()
            return func(value)

        return Result.Failure(self.error)

Like fmap, bind checks the current outcome. On success, it passes the value to func. The key difference is return func(value): the callback already returns a Result, so we do not add another wrapper. On failure, bind skips the callback and returns a failure containing the original error.

The callback has the type T_co → Result[Err, U]. The combined result has the type Result[E_co | Err, U]: an error from either step, or a successful value from the second step.

Compare the earlier nested result with this:

flat: Result[PortError | HostError, str] = (
    parse_port("8080").bind(describe_host)
)

This returns Result.Success("localhost:8080"), with only one result layer. An invalid port produces a PortError and skips describe_host; a host failure produces a HostError directly, rather than a success containing another failure.

Now we can rewrite our earlier function to accept both inputs without repeating the success checks:

def describe_endpoint(
    port_text: str, host_text: str
) -> Result[PortError | HostError, str]:
    return parse_port(port_text).bind(
        lambda port: parse_host(host_text).fmap(lambda host: f"{host}:{port}")
    )

Here, bind connects parsing the port to the next operation, which can also fail. Inside that operation, fmap combines a successfully parsed host with the port to produce an ordinary string. The lambdas capture the values needed to build the endpoint; there are no repeated error-handling branches.

For describe_endpoint("8080", "localhost"), the result is Result.Success("localhost:8080"). With "invalid" as the port, bind skips the next operation and returns the port failure. With "8080" and an empty host, fmap skips formatting and the host failure passes back through bind.

Use fmap for a function T → U, and bind for a function T → Result[Err, U]. Both skip the callback on an existing failure. Neither catches exceptions raised by the callback; expected failures still need to be returned as values.

Using | as an Infix Form of bind

We can also write bind as an infix operator, placing it between a result and the next function. Python lets us define the behavior of | through __or__. Add this method to Result:

    def __or__[Err: Exception, U](
        self,
        f: Callable[[T_co], "Result[Err, U]"],
    ) -> "Result[E_co | Err, U]":
        return self.bind(f)

The method delegates directly to bind, so result | func means result.bind(func). It has the same return type and the same behavior: pass a successful value to the next function, or carry a failure forward without calling it.

Our earlier example can now be written as:

endpoint_result: Result[PortError | HostError, str] = (
    parse_port("8080") | describe_host
)

This returns Result.Success("localhost:8080"). Replacing "8080" with "invalid" returns the port failure and skips describe_host.

We can use the operator in the version that accepts both inputs too:

def describe_endpoint(
    port_text: str, host_text: str
) -> Result[PortError | HostError, str]:
    return parse_port(port_text) | (
        lambda port: parse_host(host_text).fmap(lambda host: f"{host}:{port}")
    )

The parentheses make the lambda the right-hand operand of |. The operation still uses fmap for the final transformation into a string, because that transformation returns a plain value. The | operator connects the step that returns another result.

For a longer sequence, result | first_step | second_step groups as (result | first_step) | second_step. Each step runs only if the previous result is successful. Once a step returns a failure, later callbacks are skipped and that error is carried forward.

Here, | is our overloaded operator on result objects. Python's logical or keyword cannot be overloaded and does not perform this composition. The | inside a type annotation, such as PortError | HostError, continues to describe a union of types.

Combining Multiple Results

So far, each step has taken one successful value. What happens when a function needs three values, each stored in a separate result?

g: (A, B, C) → T

r_1: Result[E, A]
r_2: Result[E, B]
r_3: Result[E, C]

desired output: Result[E, T]

We cannot call g(r_1, r_2, r_3), because g expects the values inside those results. Calling r_1.fmap(g) does not work either: fmap passes one argument, but g needs three.

We can solve this with bind and fmap, but notice the shape of the code:

def combine_results[E: Exception, A, B, C, T](
    g: Callable[[A, B, C], T],
    r_1: Result[E, A],
    r_2: Result[E, B],
    r_3: Result[E, C],
) -> Result[E, T]:
    return r_1.bind(
        lambda a: r_2.bind(
            lambda b: r_3.fmap(lambda c: g(a, b, c))
        )
    )

The first lambda receives a and keeps it available while we inspect r_2. The second receives b and keeps both values available while we inspect r_3. Only the innermost lambda has all three arguments needed to call g. That final step uses fmap, because g returns an ordinary T, rather than another result.

The explicit error branches are gone, but nesting has returned through lambdas. Reaching the actual operation, g(a, b, c), requires following several layers that collect its arguments.

The infix operator shortens the method calls, but it does not remove those layers:

def combine_with_operator[E: Exception, A, B, C, T](
    g: Callable[[A, B, C], T],
    r_1: Result[E, A],
    r_2: Result[E, B],
    r_3: Result[E, C],
) -> Result[E, T]:
    return r_1 | (
        lambda a: r_2 | (
            lambda b: r_3.fmap(lambda c: g(a, b, c))
        )
    )

For example, suppose we want to build a URL from a host, a port, and a path:

def build_url(host: str, port: int, path: str) -> str:
    return f"http://{host}:{port}/{path}"


host_result: Result[ValueError, str] = Result.Success("localhost")
port_result: Result[ValueError, int] = Result.Success(8080)
path_result: Result[ValueError, str] = Result.Success("health")

url_result: Result[ValueError, str] = combine_with_operator(
    build_url, host_result, port_result, path_result
)

This produces Result.Success("http://localhost:8080/health"). If any input is a failure, the combination returns the first error in left-to-right order and never calls build_url. The three input results already exist, so this does not undo or skip the work that originally produced them; it skips the later callbacks that would read their successful values.

The helper hides the nesting at the call site. Inside it, however, we still collect arguments one at a time. We want to express the whole sequence without adding a lambda for every argument.

A Hypothetical do Syntax

The nested lambdas express a simple sequence: take a value from each successful result, then call g. A small amount of syntax could make that sequence visible without exposing the nesting.

Suppose g takes n arguments of types T_1 through T_n and returns a value of type R. Each argument is stored in a result, r_i: Result[E, T_i]. We want to combine them into one final result of type Result[E, R].

Imagine the following syntax. This is pseudocode, not valid Python:

do as r_final:
    x_1 <- r_1
    x_2 <- r_2
    x_3 <- r_3
    ...
    x_n <- r_n
    return g(x_1, x_2, x_3, ..., x_n)

Each <- would bind the successful value of a result to a name. On failure, the block would stop and assign that failure to r_final, skipping the remaining bindings and the call to g. If every binding succeeded, the final return would wrap g's output in Result.Success and assign it to r_final. Here, return belongs to the hypothetical block; it does not return from the surrounding Python function.

The block would hide the repeated success checks and nested lambdas, while keeping the sequence visible. It would not change how errors are represented or handled.

We can model the same behavior in ordinary Python with a recursive helper. In the following example, results contains r_1 through r_n in argument order:

from collections.abc import Callable
from typing import Any


def do_result[E: Exception, R](
    g: Callable[..., R], *results: Result[E, Any]
) -> Result[E, R]:
    def step(index: int, values: tuple[Any, ...]) -> Result[E, R]:
        if index == len(results):
            return Result.Success(g(*values))

        return results[index] | (
            lambda value: step(index + 1, (*values, value))
        )

    return step(0, ())


r_final = do_result(g, *results)

step keeps the values collected so far in a tuple. Each | passes the next successful value to a lambda, which appends it to that tuple and continues. Once all values are available, the helper calls g and wraps its output in a success. If a result is a failure, | skips the lambda and carries the same error back through the earlier calls. The first failure in argument order therefore becomes the final outcome.

The input results already exist before either version combines them. Stopping at a failure skips the remaining bindings and callbacks; it does not skip work that was already done to create those results. As with bind, exceptions raised by g still propagate rather than becoming returned failures.

The helper models the nested-bind expansion rather than implementing new syntax. Its use of Any and Callable[..., R] loses the relationship between each argument's type and the corresponding parameter of g. Its recursive calls also limit the sequence length. It illustrates the translation; it is not a general-purpose implementation for arbitrarily long sequences.

There Is a Package for This

We built a small Result type here to explain the ideas step by step. For a walkthrough of handling errors as values using Katharos, see Handling Errors as Values in Python with Katharos.

Our implementation is a teaching example. The linked walkthrough is a starting point for using a package rather than building the supporting tools yourself.

Making Expected Failures Ordinary

We introduced Result to name both success and expected failure in the return type. Then fmap and bind let us reuse the logic for transforming successful values and forwarding errors. The infix operator shortened the notation, while the hypothetical do block showed how collecting several values could read as a flat sequence.

A signature such as Result[PortError, int] supports "Explicit is better than implicit": it names an expected failure alongside the successful value. The exception object is returned as data, and callers can see the intended outcomes without searching for raise statements inside the function.

Reusable composition supports "Readability counts," "Simple is better than complex," and "Flat is better than nested" by reducing repeated branching. It does not make every expression elegant, as the multi-argument examples showed. The proposed do syntax remains hypothetical, but it makes the remaining readability problem clear.

Expected failures also become nonexceptional. Invalid input follows the same return path as success, which fits the spirit of "Special cases aren't special enough to break the rules." Deliberate panic checks and unexpected exceptions can still raise, and callers can still ignore returned failures. For outcomes the application expects to handle, though, explicit types and ordinary return values offer a more consistent model. Judged by those values of the Zen, that is the argument for calling this approach more Pythonic.