We can't find the internet
Attempting to reconnect
Something went wrong!
Attempting to reconnect
← Failure by Design step 16 of 18
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 catching — except 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…
Stuck?
Python reference solution
Sign in to attempt this problem and reveal the reference solution.