Skip to content

← Error Handling with Exceptions, Retries and Warnings step 20 of 20

Hard Primitives

Return Errors as Values with a Result Type

In Type Hints for Code That Can Fail, parse_count returned Parsed | Invalid. Here you build the reusable version. Ok holds a success, Err holds a failure, and attempt calls a function and turns the exceptions you choose into an Err:

attempt(lambda: int("21"), (ValueError,))  # Ok(value=21)
attempt(lambda: int("x"), (ValueError,))   # Err(error=ValueError("invalid literal for int() with base 10: 'x'"))
attempt(lambda: int("x"), (KeyError,))     # raises the ValueError

Syntax you need

  • class Ok[T, E]: makes Ok a generic class. T and E are type parameters, placeholders that each use fills in, so an Ok[int, ValueError] holds an int.
  • @dataclass(frozen=True, slots=True), already in the starter, writes __init__, __repr__ and __eq__ from the fields. frozen=True makes the fields read-only, and slots=True stores them without a per-instance dictionary.
  • type Result[T, E] = Ok[T, E] | Err[T, E] gives the union a name.
  • Callable[[T], U] is a function that takes a T and returns a U.
  • Never is the return type of a function that always raises, as in Mark Functions That Never Return.

Ok and Err

Ok[T, E] has one field, value: T. Err[T, E] has one field, error: E. Each class takes both type parameters, although it stores only one, so its methods can name the other side's type. Give both classes these methods:

Method On Ok On Err
is_ok() -> bool Return True Return False
map(fn) Call fn once on the value and return Ok of the result Return Err with the same error, without calling fn
map_err(fn) Return Ok with the same value, without calling fn Call fn once on the error and return Err of the result
unwrap() Return the value Raise UnwrapError
unwrap_or(default) Return the value Return default
  • map changes T to a new type U, and map_err changes E to a new type F. Declare each new parameter on its method, as in def map[U](self, fn: Callable[[T], U]) -> "Ok[U, E]".
  • The side that does not change may return a new wrapper around the same stored object.
  • Let exceptions from fn escape. map and map_err catch nothing.
  • unwrap_or(default: T) -> T returns the value of any Ok, even 0 or "". Do not test truthiness.
  • Err.unwrap() returns Never and raises UnwrapError(f"unwrap() called on Err({self.error!r})"). Then unwrap() on a Result[T, E] has the type T.
  • is_ok() returns a plain bool, which mypy cannot use to narrow the union. Check isinstance(result, Err) before you read .error.

attempt

attempt(fn, catching) calls fn once, with no arguments.

  • If fn returns, return Ok with its value.
  • If fn raises an exception that matches a class in catching, return Err holding that same exception object, so its notes and other attributes survive.
  • Matching includes subclasses, as in an except clause. (LookupError,) catches a KeyError, but (KeyError,) does not catch a plain LookupError.
  • Any other exception escapes unchanged. An empty tuple catches nothing.

E: BaseException in the signature is a bound. It lets a caller ask attempt to catch any exception class, even KeyboardInterrupt, which inherits from BaseException but not from Exception. solve below asks only for ValueError and KeyError.

solve

solve(source, fallback) builds a producer function, runs it through attempt with (ValueError, KeyError), and reports the outcome. The producer:

  • raises when source.get("kind") == "raise". It looks up the class named source.get("exc", "ValueError") in RAISABLE and raises it with the message source.get("message", ""). An unknown class name makes the lookup raise KeyError, which attempt catches like any other KeyError.
  • otherwise returns int(source.get("value", "0")). Text that is not a whole number raises ValueError.
  • ignores any other keys.

On the Result that attempt returns, map the number to twice its value, then to a string. Map the error to its class name, type(exc).__name__. Report on that final Result.

Wrap the attempt call in try/except BaseException, so an exception that escapes it becomes a report too. Keep that catch separate from the catching inside attempt. Return exactly these keys:

Key Result returned Exception escaped
"outcome" "handled" "escaped"
"escaped_type" None The exception's class name
"is_ok" The final result's is_ok() None
"err" The Err's error, now a class name, otherwise None None
"unwrap_or" The success string, or fallback for an Err None
"unwrap_or_type" "str" None
"unwrapped" The success string, otherwise None None
"unwrap_raised" "UnwrapError" for an Err, otherwise None None

For {"value": "21"}, the success string is "42". For a caught ValueError, err is "ValueError" and unwrap_or is fallback. A TypeError or KeyboardInterrupt from the producer escapes attempt and uses the second column.