Skip to content

← Failure by Design step 16 of 18

Hard Primitives

Typing the error path: Result, attempt and Never

The exception hierarchy is part of a function’s signature, and Python’s type system cannot express it. def charge(...) -> Receipt says nothing about the four things it raises. Java tried checked exceptions and the industry decided the cure was worse; Python never tried. So the discipline has to come from somewhere else: from documentation, from tests, and — at the boundaries that matter most — from moving the error into the return type where the checker can see it.

That is what a Result is. It is not a house style for Python and this problem is not an argument that it should be. Without a ? operator the ergonomics are genuinely worse: every call site has to unwrap, and a chain of five fallible calls in Rust is five characters of syntax and in Python is five if statements or a map chain that reads sideways. Use it where the payoff is real — one library boundary, one parser, one place where “this can fail and the caller must decide” is the entire point — and use plain exceptions everywhere else.

What to build

Two frozen, slotted dataclasses and a type alias:

type Result[T, E] = Ok[T, E] | Err[T, E]

Both classes carry both parameters. Ok[T, E] never holds an E and Err[T, E] never holds a T, but declaring both is what makes .map() and .map_err() compose on the union without the checker losing track of the half you are not touching. A phantom type parameter is a normal, load-bearing tool, not a hack.

Each class needs five methods:

method on Ok on Err
is_ok() True False
map(fn) Ok(fn(self.value)) itself, retyped
map_err(fn) itself, retyped Err(fn(self.error))
unwrap() the value raises UnwrapError
unwrap_or(default) the value the default

Err.unwrap() is annotated -> Never. That is the whole of item 6.8 in one line: Never says “this call does not come back”, so on the union Ok[str, E] | Err[str, E] mypy joins str with Never and gets str — the happy path stays precisely typed even though one branch cannot return. Annotate it -> None instead and every caller of unwrap() starts seeing str | None, and the Result type has made your code worse.

Then the constructor:

def attempt[T, E: BaseException](
    fn: Callable[[], T], catching: tuple[type[E], ...]
) -> Result[T, E]:

Call fn(). Catch exactly the classes in catchingexcept catching as exc is legal and correctly typed when catching is a tuple of exception types — and return Err(exc). Anything not in that tuple propagates. Note that the tuple is exact-superclass matching, not “anything error-shaped”: (ValueError, KeyError) does not catch a LookupError, even though KeyError is one of its subclasses. Inheritance runs the other way.

The probe

def solve(source: Mapping[str, str], fallback: str) -> dict[str, object]:

Build a producer: when source["kind"] == "raise", raise RAISABLE[source["exc"]](source["message"]); otherwise return int(source["value"]). Run it through

result = attempt(produce, (ValueError, KeyError))
labelled = result.map(lambda n: n * 2).map(str)
named = labelled.map_err(lambda exc: type(exc).__name__)

and report. If the producer raised something outside catching, catch it at the boundary and report that instead.

Return exactly these keys:

key value
"outcome" "handled" or "escaped"
"escaped_type" class name of the escaping exception, else None
"is_ok" named.is_ok(), or None when escaped
"err" the mapped error string when named is an Err, else None
"unwrap_or" named.unwrap_or(fallback), or None when escaped
"unwrap_or_type" the class name of that value — "str", which is the assertion that map(str) really did change the type parameter
"unwrapped" named.unwrap() when it returns, else None
"unwrap_raised" "UnwrapError" when unwrap() raised, else None

Reading named.error needs isinstance(named, Err) first. That is the type system doing its job: on a union you cannot reach into one arm until you have proved which arm you have. It is also why Result is worth anything at all — the compiler will not let you forget the error case, which is precisely what an unchecked except lets you do.

The three-line version of the honest verdict

Use exceptions for the 95%. Reach for a Result when the error is an expected outcome rather than an exceptional one, when the caller must handle it, and when you can keep the unwrapping to one boundary. Then write it once, type it properly, and do not spread it through the codebase.

Loading visualization…