
- 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) -> dictis lying to you. It does not always return adict: it might raiseOSError,JSONDecodeError, orKeyErrorinstead. 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
raisedeep inside a helper can land in atryblock several frames up. Following the happy path means mentally skipping over every point that might jump elsewhere. tryblocks are all-or-nothing. Wrap a wide block and you catch more than you meant to (swallowing theKeyErroryou 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.Failuremake "this might fail" part of the return type, where the caller and the type checker can both see it.|composesResult-returning steps into a short-circuiting pipeline, notryblocks required.Result.catchremoves 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.