How to Handle Errors Without Exceptions¶
This guide covers practical patterns for using Result and Maybe to represent and propagate errors without raising exceptions — including wrapping existing exception-throwing code, recovering from failures, and converting between the two types.
Prerequisites¶
Familiarity with
Result.Success/Result.Failureand the|operatorFamiliarity with
Maybe.Just/Maybe.Nothing
Wrapping code that raises exceptions¶
Convert an exception-throwing call into a Result using a try/except at the boundary:
from katharos.types import Result
def read_file(path: str) -> Result[Exception, str]:
try:
with open(path) as f:
return Result.Success(f.read())
except OSError as e:
return Result.Failure(e)
def parse_json(text: str) -> Result[Exception, dict]:
import json
try:
return Result.Success(json.loads(text))
except json.JSONDecodeError as e:
return Result.Failure(e)
Chain these with | — a failure at any step short-circuits the rest:
config = (
read_file("config.json")
| parse_json
)
if config.is_success():
print(config.value)
else:
print(f"Could not load config: {config.error}")
Converting a None-returning function to Maybe¶
Wrap a call that returns None on absence with Maybe:
from katharos.types import Maybe
def find_user(uid: int) -> Maybe[dict]:
db = {1: {"name": "Alice"}, 2: {"name": "Bob"}}
user = db.get(uid) # returns None when absent
return Maybe.Just(user) if user is not None else Maybe.Nothing()
Providing a default value on Nothing¶
Use a conditional after the pipeline to supply a fallback:
user = find_user(99)
name = user.fmap(lambda u: u["name"])
display = name.unwrap() if name.is_just() else "Guest"
print(display) # Guest
Recovering from a Failure¶
To attempt a fallback when a Result fails, branch on .is_failure():
def read_file_or_default(path: str, default: str) -> str:
result = read_file(path)
if result.is_failure():
return default
return result.value
For a monadic fallback — where the recovery itself may also fail — use a plain conditional rather than chaining |, since | only runs when the preceding step succeeded:
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
Using do-notation to flatten multi-step pipelines¶
When several Result-returning steps depend on one another, the @do decorator
lets you write them as a plain sequence of bindings instead of a | chain.
Each yield unwraps the value on success; the first Failure short-circuits
the entire block and is returned immediately — no subsequent steps run.
Rewriting the config pipeline with do-notation:
from katharos.syntax_sugar import do, DoBlock
from katharos.types import Result
@do(Result)
def load_config(path: str) -> DoBlock[dict]:
text: str = yield read_file(path)
config: dict = yield parse_json(text)
return config
result = load_config("config.json")
if result.is_success():
print(result.value)
else:
print(f"Could not load config: {result.error}")
If read_file returns a Failure, the block stops there and parse_json is
never called. If parse_json fails, that Failure is what the block returns.
Do-notation is especially readable when intermediate values need to be used in later steps:
def get_setting(config: dict, key: str) -> Result[Exception, str]:
if key not in config:
return Result.Failure(KeyError(f"missing key: {key!r}"))
return Result.Success(config[key])
@do(Result)
def load_host_and_port(path: str) -> DoBlock[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
load_host_and_port("config.json")
# Success(('localhost', '8080')) — or Failure at whichever step first fails
Converting between Maybe and Result¶
Convert Maybe to Result when you need to attach an error message to an absence:
def maybe_to_result(m: Maybe[dict], uid: int) -> Result[Exception, dict]:
if m.is_just():
return Result.Success(m.unwrap())
return Result.Failure(KeyError(f"User {uid} not found"))
user_result = maybe_to_result(find_user(42), 42)
Convert Result to Maybe when you want to discard the error and treat failure as absence:
def result_to_maybe(r: Result) -> Maybe:
if r.is_success():
return Maybe.Just(r.value)
return Maybe.Nothing()
Accumulating multiple independent errors¶
When validating several independent fields and you want all failures at once (not just the first), collect them explicitly before constructing a Result:
def validate_name(name: str) -> Result[Exception, str]:
if not name.strip():
return Result.Failure(ValueError("name cannot be blank"))
return Result.Success(name.strip())
def validate_age(age: int) -> Result[Exception, int]:
if age < 0 or age > 150:
return Result.Failure(ValueError(f"age {age} is out of range"))
return Result.Success(age)
def validate_user(name: str, age: int) -> Result[Exception, dict]:
name_result = validate_name(name)
age_result = validate_age(age)
errors = [
r.error for r in [name_result, age_result] if r.is_failure()
]
if errors:
combined = ValueError("; ".join(str(e) for e in errors))
return Result.Failure(combined)
return Result.Success({"name": name_result.value, "age": age_result.value})
print(validate_user("", -5))
# Failure(ValueError('name cannot be blank; age -5 is out of range'))