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 keepscatchfrom swallowing bugs: aValueErroryou expected becomes a value, but an unexpectedTypeErrorfrom a genuine programming error still crashes loudly, the way it should.Composability. The wrapped function returns a plain
Result, so it slots into the samefmap/bind/|pipelines as everything else in the library.catchdoes 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¶
Why Functional Programming in Python? — the broader case for making failure and absence explicit in the type system
How to Convert Exception-Throwing Functions to Result — practical recipes for using
Result.catchBuild a User Registration System with Result — build a validation pipeline with
Result