Handling Errors as Values in Python with Katharos
  • Python
  • Functional Programming

Handling Errors as Values in Python with Katharos

Errors are not exceptional. A config file that does not exist, JSON that does not parse, a missing key: these are ordinary outcomes your program should expect and deal with. Yet Python's default tool for them, the exception, treats every error as a surprise that interrupts control flow and travels invisibly up the call stack.

This post takes a small but realistic example, a config loader, written in the usual exception style, and refactors it step by step into the "errors as values" style using katharos and its Result type. By the end, every way the loader can fail will be visible in its type signature, composable with operators, and impossible to forget.

The problem with exceptions

Exceptions are convenient, but they have real costs:

  • They are invisible in signatures. A function typed as def load_config(path: str) -> dict is lying to you. It does not always return a dict: it might raise OSError, JSONDecodeError, or KeyError instead. Nothing in the type tells the caller what can go wrong, so nothing forces the caller to handle it.
  • They are easy to forget. Because the type does not mention failure, an uncaught exception slips through and crashes the program far from where the problem actually started.
  • They create non-local control flow. A raise deep inside a helper can land in a try block several frames up. Following the happy path means mentally skipping over every point that might jump elsewhere.
  • try blocks are all-or-nothing. Wrap a wide block and you catch more than you meant to (swallowing the KeyError you wanted to let propagate). Wrap each line narrowly and the real logic drowns in boilerplate.

The exception-based version

Here is a config loader written the way most Python is written. It reads a file, parses it as JSON, and pulls out two settings:

import json


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


def parse_json(text: str) -> dict:
    return json.loads(text)


def load_host_and_port(path: str) -> tuple[str, str]:
    text = read_file(path)
    config = parse_json(text)
    return config["host"], config["port"]


# At the call site, the caller has to remember which exceptions can occur:
try:
    host, port = load_host_and_port("config.json")
    print(f"Connecting to {host}:{port}")
except OSError as e:
    print(f"Could not read config: {e}")
except json.JSONDecodeError as e:
    print(f"Config is not valid JSON: {e}")
except KeyError as e:
    print(f"Missing setting: {e}")

Read load_host_and_port again. Its signature promises a tuple[str, str], but three different exceptions can come out of it instead, and the only way to know that is to read the body of every function it calls. The happy path (three short lines) is dwarfed by the try/except ceremony at the call site, and if the caller forgets one of those except clauses the program simply crashes.

Let us fix this by making failure a value the function returns, not an event it throws.

Step 1: Return a Result at the boundary

Result[E, A] is a value that is either a Success(a) holding a result of type A, or a Failure(e) holding an error of type E. (In katharos, E is bound to BaseException, so the error you carry is always a real exception instance, traceback and all.)

We start by rewriting the two boundary functions to catch at the edge and return a Result instead of raising:

import json

from katharos.types import Result


def read_file(path: str) -> Result[Exception, str]:
    try:
        with open(path) as f:
            return Result[Exception, str].Success(f.read())
    except OSError as e:
        return Result[Exception, str].Failure(e)


def parse_json(text: str) -> Result[Exception, dict]:
    try:
        return Result[Exception, dict].Success(json.loads(text))
    except json.JSONDecodeError as e:
        return Result[Exception, dict].Failure(e)

Writing Result[Exception, str].Success(...) rather than the bare Result.Success(...) spells out both type parameters at the construction site: the error type (Exception) and the success type (str). It is a little more verbose, but it pins down exactly what the Result carries, so the type checker can catch a mismatch (say, a Failure holding the wrong kind of error) right where the value is built.

Already something important changed: the return type now tells the truth. read_file returns a Result[Exception, str], so anyone calling it can see, from the signature alone, that it might fail, and the type checker will not let them treat the value as a plain str until they deal with that possibility.

Step 2: Chain with |

We have two functions that each return a Result. We want to feed the success of the first into the second, and stop immediately if either fails. That is exactly what the bind operator | does:

config = read_file("config.json") | parse_json

if config.is_success():
    print(config.value)
else:
    print(f"Could not load config: {config.error}")

read_file("config.json") | parse_json reads as "read the file, and then, if that succeeded, parse it." If read_file returns a Failure, parse_json is never called and the original failure passes straight through. This is short-circuiting, the same early-exit behavior a chain of exceptions gives you, except here it is a plain value flowing through a pipeline rather than a jump in control flow.

At the end we ask the value what it is: is_success() / is_failure() tell us the state, .value extracts the success, and .error extracts the exception. There is no try block in sight, and no way to accidentally forget the failure case: the config value forces you to consider it.

Step 3: Drop the boilerplate with Result.catch

Writing try/except by hand in every boundary function gets repetitive. Katharos ships a decorator factory, Result.catch, that does it for you. You give it the exception type to intercept, and it turns a throwing function into a Result-returning one:

import json

from katharos.types import Result


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


@Result.catch(json.JSONDecodeError)
def parse_json(text: str) -> dict:
    return json.loads(text)

The bodies are back to the plain, readable versions from the very first example, but read_file and parse_json now return Result[OSError, str] and Result[json.JSONDecodeError, dict]. Only the declared exception type is caught; anything else propagates as usual, so you do not accidentally swallow bugs you did not anticipate. The original exception instance, including its traceback, is preserved inside the Failure, so nothing is lost.

A tiny standalone example makes the behavior concrete:

from katharos.types import Result


@Result.catch(ZeroDivisionError)
def divide(a: float, b: float) -> float:
    return a / b


print(divide(10.0, 2.0))   # Success(5.0)
print(divide(10.0, 0.0))   # Failure(ZeroDivisionError('float division by zero'))

Step 4: Flatten multi-step logic with do-notation

The | operator is great for a straight chain, but our real pipeline needs to pull two values out of the parsed config and use both. Let us add a helper that looks up a key as a Result:

from katharos.types import Result


def get_setting(config: dict, key: str) -> Result[Exception, str]:
    if key not in config:
        return Result[Exception, str].Failure(KeyError(f"missing key: {key!r}"))
    return Result[Exception, str].Success(config[key])

Now we want to: read the file, parse it, get host, get port, and return both, stopping at the first failure. Expressing that as a | chain gets awkward, because later steps need values from earlier ones. The @do(Result) decorator lets you write it as a flat sequence instead. Each yield unwraps a success and binds it to a name; the first Failure short-circuits the whole block:

from katharos.syntax_sugar import do, DoBlock
from katharos.types import Result


@do(Result)
def load_host_and_port(path: str) -> DoBlock[Result, tuple[str, str]]:
    text:   str  = yield read_file(path)
    config: dict = yield parse_json(text)
    host:   str  = yield get_setting(config, "host")
    port:   str  = yield get_setting(config, "port")
    return host, port


result = load_host_and_port("config.json")

if result.is_success():
    host, port = result.value
    print(f"Connecting to {host}:{port}")
else:
    print(f"Could not load config: {result.error}")

Compare this to the original exception version. The logic reads top to bottom as plainly as the imperative code did, but there is no try/except, no invisible control flow, and the function's type, Result[..., tuple[str, str]], openly advertises that it can fail. If the file is missing, parse_json and both get_setting calls never run, and the OSError failure is what the block returns. The plain return host, port is automatically lifted back into a Success for you.

Recovering from a failure

Sometimes a failure is not the end: you want to try a fallback. Because | only continues on success, a recovery (which runs precisely because the first attempt failed) is just a plain conditional on .is_failure():

def load_config(primary: str, fallback: str) -> Result[Exception, dict]:
    result = read_file(primary) | parse_json
    if result.is_failure():
        return read_file(fallback) | parse_json
    return result

The fallback path is itself a Result-returning pipeline, so if both the primary and fallback fail, you still get a Failure out, never an exception.

Combining independent values

The pipeline above is sequential: each step depends on the previous one. When you instead have several independent results and want to combine them, F.lift_a2 lifts an ordinary two-argument function so it works on Result values, failing if either side failed:

from katharos.functools import F
from katharos.types import Result

host: Result[Exception, str] = Result[Exception, str].Success("localhost")
port: Result[Exception, int] = Result[Exception, int].Success(8080)
print(F.lift_a2(lambda a, b: f"{a}:{b}", host, port))
# Success('localhost:8080')

missing_host: Result[Exception, str] = Result[Exception, str].Failure(KeyError("host"))
print(F.lift_a2(lambda a, b: f"{a}:{b}", missing_host, port))
# Failure(KeyError('host'))

Wrapping up

We started with a config loader whose failure modes were hidden inside its body and scattered across except clauses at the call site. Step by step, we turned each failure into a value:

  • Result.Success / Result.Failure make "this might fail" part of the return type, where the caller and the type checker can both see it.
  • | composes Result-returning steps into a short-circuiting pipeline, no try blocks required.
  • Result.catch removes the boilerplate of wrapping throwing code, while keeping the original exception and traceback.
  • @do(Result) flattens multi-step logic that threads values between steps back into straight-line code.

Errors stopped being surprises that interrupt your program and became ordinary values you pass around, transform, and handle, exactly like any other data.