Errors as Values: catch and Traceback Preservation

Katharos models failure as a value — a Result that is either a Success or a Failure — rather than as control flow that unwinds the stack. This article explains why Result.catch exists, why it is shaped as a decorator factory, and why it goes out of its way to preserve the original exception’s traceback.

Exceptions as values, not control flow

In ordinary Python, a failure is an event: an exception is raised, the current call stack unwinds, and execution jumps to whichever try/except happens to be waiting up the stack. The function’s signature says nothing about this — def parse_config(path: str) -> Config gives no hint that it might raise FileNotFoundError or ValueError. The possibility of failure is invisible until it happens at runtime.

The functional approach makes failure part of the return type instead. Result[E, A] says, in the signature, this computation may fail with an error of type E. The caller cannot accidentally ignore it: they must handle the failure, propagate it with |, or consciously call .unwrap(). Failure becomes a value you pass around and compose, not an exception you hope someone catches.

Why a decorator factory

A great deal of Python code already raises exceptions — the standard library, third-party packages, your own existing functions. Result.catch is the bridge between that world and the errors-as-values world. Rather than hand-write the same try/except wrapper at every boundary:

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

you declare the exception you expect and let catch generate the wrapper:

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

The decorator-factory shape — catch(ExceptionType) returns a decorator — is what lets you name the exception type at the call site. That choice carries two deliberate design decisions:

  • Selective catching. Only the declared type (and its subclasses) becomes a Failure. Every other exception propagates untouched. This keeps catch from swallowing bugs: a ValueError you expected becomes a value, but an unexpected TypeError from a genuine programming error still crashes loudly, the way it should.

  • Composability. The wrapped function returns a plain Result, so it slots into the same fmap / bind / | pipelines as everything else in the library. catch does not introduce a new kind of value to special-case; it lifts existing functions into the type you already use.

Why preserving the traceback matters

The usual objection to errors-as-values is that you lose the debugging information a traceback gives you. When you catch an exception and replace it with, say, a string message or a custom error code, the stack — the precise line that failed — is gone.

catch avoids this. It stores the original exception instance in the Failure; it does not re-raise it, wrap it in a new exception, or reduce it to a message. When Python raises an exception, it attaches the traceback to that instance as __traceback__, and because catch keeps the instance intact, the traceback travels with it:

import traceback

result = parse_int("bad")
if result.is_failure():
    traceback.print_exception(result.error)        # shows the failing line
    frames = traceback.format_tb(result.error.__traceback__)

This is the point: you get the discipline of errors-as-values — failure visible in the type, impossible to ignore, composable with the rest of your pipeline — without giving up the debuggability of exceptions. The failing line is still recoverable, just from a value you chose when to inspect rather than from a stack unwind you had to catch in the right place.

The trade-off

catch is the right tool when the failures are expected and you want to handle them as data: parsing, I/O, validation, anything where “this might fail” is part of the normal flow. It is not a replacement for letting genuine bugs crash — which is exactly why it catches only the type you name. Use it to convert the exceptions you anticipate into values, and let everything else propagate.

Further reading