While grokking Black formatter’s codebase, I came across this1 interesting way of handling exceptions in Python. Exception handling in Python usually follows the EAFP paradigm where it’s easier to ask for forgiveness than permission.

However, Rust has this recoverable error2 handling workflow that leverages generic Enums. I wanted to explore how Black emulates that in Python. This is how it works:

# src.py
from __future__ import annotations

from typing import Generic, TypeVar, Union

T = TypeVar("T")
E = TypeVar("E", bound=Exception)


class Ok(Generic[T]):
    def __init__(self, value: T) -> None:
        self._value = value

    def ok(self) -> T:
        return self._value


class Err(Generic[E]):
    def __init__(self, e: E) -> None:
        self._e = e

    def err(self) -> E:
        return self._e


Result = Union[Ok[T], Err[E]]

In the above snippet, two generic types Ok and Err represent the return type and the error types of a callable respectively. These two generics were then combined into one Result generic type. You’d use the Result generic to handle exceptions as follows:

# src.py
...


def div(dividend: int, divisor: int) -> Result[int, ZeroDivisionError]:
    if divisor == 0:
        return Err(ZeroDivisionError("Zero division error occurred!"))

    return Ok(dividend // divisor)


if __name__ == "__main__":
    result = div(10, 0)
    if isinstance(result, Ok):
        print(result.ok())
    else:
        print(result.err())

This will print:

Zero division error occurred!

If you run Mypy on the snippet, it’ll succeed as well.

You can also apply constraints on the return or exception types as follows:

# src.py
...
# Only int, float, and str types are allowed as input.
Convertible = TypeVar("Convertible", int, float, str)

# Create a more specialized generic type from Result.
IntResult = Result[int, TypeError]


def to_int(num: Convertible) -> IntResult:
    """Converts a convertible input to an integer."""

    if not isinstance(num, (int, float, str)):
        return Err(
            TypeError(
                "Input type is not convertible to an integer type.",
            )
        )

    return Ok(int(num))


if __name__ == "__main__":
    result = to_int(1 + 2j)

    if isinstance(result, Ok):
        print(result.ok())
    else:
        print(result.err())

Running the script will give you this:

Input type is not convertible to an integer type.

In this case, Mypy will catch the type inconsistency before runtime.

Black extensively uses this pattern3 in the transformation part of the codebase. This showed me another way of thinking about handling recoverable exceptions while ensuring type safety in a Python codebase.

However, I wouldn’t go about and mindlessly refactor any exception handling logic that I come across to follow this pattern. You might find it useful if you need to handle exceptions in a recoverable fashion and need additional type safety around the logic.

Recent posts

  • TypeIs does what I thought TypeGuard would do in Python
  • ETag and HTTP caching
  • Crossing the CORS crossroad
  • Dysfunctional options pattern in Go
  • Einstellung effect
  • Strategy pattern in Go
  • Anemic stack traces in Go
  • Retry function in Go
  • Type assertion vs type switches in Go
  • Patching pydantic settings in pytest