Skip to content

Features

Basic Usage

from safe import safe

@safe @ ValueError
def foo(a: int) -> str:
    if a < 0:
        raise ValueError
    return f"Hello {a} times"

The entry point is the @safe decorator, where the exception types that may occur should be specified after the @.

The resulting function will have the following type:

foo(42) # -> Success[str] | Failure[str, ValueError]

Subsequent work with the function result involves type refinement:

from safe import Success, Failure

if isinstance(result := foo(42), Success):
    print(result.value)
else:
    print("We encountered an error!", result.error)

Specifying Exceptions

The specified exceptions will be guaranteed to be caught, including their subclasses. However, exceptions that are not specified will not be caught and will propagate upwards:

from safe import safe

@safe @ TypeError
def foo():
    raise ValueError

try:
    result = foo()
except ValueError:
    print("Caught an unregistered exception")

The concept here is simple: anything not explicitly specified by the developer as expected is treated as unexpected behavior. Such exceptions are considered program errors that should either terminate the program or be caught at a high level if further execution is possible.

Without Specification

The decorator can be used without specifying exception types. In this case, it will only wrap results in the Success data class.

This ensures consistency in the code approach.

from safe import safe

@safe
def foo() -> float:
    return 3.22

foo() # -> Success[float]

Specifying Multiple Types

Using Pipe (|)

Exception types can be explicitly specified using the or operator |.

from safe import safe

@safe @ KeyError | ValueError
def foo() -> int: ...

Using a Collection

You can also specify exception types by passing an iterable, provided its types are analyzable by the type checker.

from safe import safe

exc_types = (KeyError, ValueError, TypeError)

@safe @ exc_types
def foo() -> int: ...

Using a Decorated Function

You can also specify another function that has been decorated with @safe.

from safe import safe

@safe @ KeyError
def foo() -> int: ...

@safe @ foo
def bar() -> float: ...

Combining Them All

Using or |, you can combine all of these approaches, passing any number of iterables, explicit types, and other functions.

from safe import safe

exc_types = (ValueError, TypeError)

@safe @ KeyError | exc_types
def foo() -> int: ...

@safe @ foo | IndexError
def bar() -> int: ...

@safe @ foo | bar | (AssertionError, ) | ArithmeticError
def zoo() -> int: ...

If types are repeated in the combination, the type checker will correctly recognize only one instance.

Working with Results

Unsafe Usage

Both Success and Failure have a property unsafe, which is typed as the function's returns_type. However, calling it on a Failure will raise the captured exception.

This can be used when one safe function calls another and does not handle its exceptions, leaving this responsibility to higher-level code.

from safe import safe

@safe @ ValueError
def foo(a: int) -> str:
    if a < 0:
        raise ValueError
    return f"It's {a}"


@safe @ foo
def bar(a: int) -> list[str]:
    return [foo(a).unsafe]

Utilities is_success and is_failure

The library provides is_success and is_failure utilities for convenience and to avoid repeated checks.

if isinstance(result := foo(5), Success):
    ...


from safe import is_success, is_failure

if is_success(result := foo(5)):
    ...
else:
    ...

if is_failure(result := foo(0)):
    ...
else:
    ...

These utilities serve as TypeGuard checks for isinstance.

Utility registered

The library provides the registered utility, which allows retrieving the registered exception types for a decorated function.

from safe import registered, safe

@safe @ ValueError | KeyError
def foo() -> None: ...

registered(foo) # {KeyError, ValueError}

Pattern Matching

The full potential of this approach is revealed when used in conjunction with the match case construct:

from safe import safe, Success, Failure

@safe @ ValueError | KeyError
def foo() -> int | str: ...


match foo():
    case Success(value=int()):
        print("It's int")
    case Success(value=str()):
        print("It's str")
    case Failure(error=ValueError()):
        print("Caught ValueError")

From the code, it is evident that the KeyError exception is not handled, and this will be detected by the type checker (pyright or mypy), flagging it with a reportMatchNotExhaustive error.

You can resolve this by adding its handling or propagating it upwards:

match foo():
    case Success(value=int() as value):
        print(f"It's int {value=}")
    case Success(value=str() as value):
        print(f"It's str {value=}")
    case Failure(error=ValueError()):
        print("Caught ValueError")
    case Failure(error as error):
        raise error

Asynchronous Support

To decorate coroutines, use the separate @async_safe decorator.

from safe import async_safe


@async_safe @ ValueError
async def foo(a: int) -> str: ...


foo # (int) -> Coroutine[Any, Any, Success[str] | Failure[str, ValueError]]