How to Convert Exception-Throwing Functions to Result

This guide shows how to use Result.catch to turn an ordinary function that raises exceptions into one that returns a Result — without writing a try/except block yourself. It also covers how to recover the exact line that caused a failure from the captured exception’s traceback.

Prerequisites

Wrap a throwing function with a single decorator

Result.catch is a decorator factory. Pass it the exception type you want to intercept; it returns a decorator that wraps the function so a normal return value becomes a Success and a matching exception becomes a Failure:

from katharos.types import Result

@Result.catch(ValueError)
def parse_int(s: str) -> int:
    return int(s)

print(parse_int("42"))    # Success(42)
print(parse_int("bad"))   # Failure(ValueError("invalid literal for int() with base 10: 'bad'"))

This replaces the manual boundary code you would otherwise write:

def parse_int(s: str) -> Result[ValueError, int]:
    try:
        return Result.Success(int(s))
    except ValueError as e:
        return Result.Failure(e)

The decorated function keeps its name and docstring (catch uses functools.wraps internally), so it behaves like the original everywhere else.

Catch only the declared type; let others propagate

Only the exception type you name (and its subclasses) is converted to a Failure. Any other exception propagates unchanged, exactly as it would without the decorator:

@Result.catch(ValueError)
def risky(x: int) -> int:
    if x < 0:
        raise TypeError("negative")
    return x

print(risky(1))    # Success(1)
risky(-1)          # raises TypeError — NOT caught, not wrapped in a Result

This is deliberate: catch narrows exception handling to the failures you expect, while genuine programming errors still surface as exceptions.

Cover a family of exceptions with a base class

Because subclasses are intercepted too, naming a base exception catches all of its descendants. For example, OSError covers FileNotFoundError, PermissionError, and the rest of the OS error hierarchy:

@Result.catch(OSError)
def read_file(path: str) -> str:
    with open(path) as f:
        return f.read()

read_file("/nonexistent")
# Failure(FileNotFoundError(2, 'No such file or directory'))

Chain the result with the rest of your pipeline

A function decorated with catch returns an ordinary Result, so it composes with fmap, bind, and the | operator like any other. A failure short-circuits the rest of the chain:

@Result.catch(ValueError)
def parse_int(s: str) -> int:
    return int(s)

print(parse_int("10").fmap(lambda n: n * 2))   # Success(20)
print(parse_int("bad").fmap(lambda n: n * 2))  # Failure(ValueError(...)) — lambda never runs

@Result.catch(ZeroDivisionError)
def reciprocal(n: int) -> float:
    return 1 / n

# parse, then divide — the first failure wins
print(parse_int("4") | reciprocal)   # Success(0.25)
print(parse_int("0") | reciprocal)   # Failure(ZeroDivisionError('division by zero'))

Recover the line that caused the failure

catch stores the original exception instance in the Failure, and Python attaches the traceback to that instance when it is raised. Because the exception is captured rather than re-raised, its __traceback__ is preserved — so you can inspect exactly where the failure originated:

import traceback
from katharos.types import Result

@Result.catch(ValueError)
def parse_int(s: str) -> int:
    return int(s)

result = parse_int("bad")

if result.is_failure():
    # Print the full traceback, including the failing line inside parse_int
    traceback.print_exception(result.error)

    # Or format it as a list of frames to log or attach to a report
    frames = traceback.format_tb(result.error.__traceback__)
    print("".join(frames))

This is the key advantage over patterns that discard the stack: you get errors-as-values and keep the debugging information you would have had from a normal exception.

See also