Skip to content

← Modelling Data with Dataclasses step 23 of 28

Hard Framework

Build a Generic Result Type with Ok and Err

A pipeline of steps can fail at any step. Instead of raising, each step can return a result, which is either Ok(value) for a success or Err(error) for a failure. You chain the steps with methods. Once a step fails, the later steps skip their work, and you never write an isinstance check between steps.

Ok(4).and_then(_halve).map(_double)  # Ok(value=4)
Ok(3).and_then(_halve).map(_double)  # Err(error='odd')

Build Ok[T] and Err[E] as generic frozen dataclasses with slots=True. Use the class Ok[T]: syntax, and no Any or # type: ignore. The starter defines the alias:

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

Write the methods

Ok[T] has one field, value: T, and Err[E] has one field, error: E. Keep the generated repr, such as Ok(value=6) and Err(error='odd').

Method On Ok[T] On Err[E]
map(fn) Call fn(value) once and return Ok of the result Return self without calling fn
map_err(fn) Return self without calling fn Call fn(error) once and return Err of the result
and_then(fn) Call fn(value) once and return what it returns, an Ok or an Err Return self without calling fn
unwrap_or(default) Return the value Return default

Let an exception from a callback propagate. These methods do not turn exceptions into an Err.

Annotate the methods

  • A method that returns self returns Self.
  • Ok.map builds a new Ok whose value can have another type, so it returns Ok[U], where U is a type parameter of the method.
  • Ok.and_then has two type parameters of its own, one for the new value and one for the new error.
  • Ok.unwrap_or is (default: T) -> T. Err.unwrap_or has its own type parameter for default and returns that type.
  • Type a callback that the method skips as Callable[[Never], object]. Never is a type with no values, so it is a subtype of every type. Callable parameters are contravariant, so any function that takes one positional argument fits. The method must not call it.

Run the pipeline

The starter gives you the step functions. Write _apply(result, op) with a match on op:

op Call What a success becomes
"double" result.map(_double) The value times two
"dec" result.map(_decrement) The value minus one
"halve" result.and_then(_halve) Ok(value // 2) for an even value, Err("odd") for an odd one
"guard" result.and_then(_nonneg) Err("negative") for a negative value, otherwise unchanged
"shout" result.map_err(_shout) Unchanged. An error becomes uppercase.
  • An unknown op raises ValueError(f"unknown op: {op!r}"), even when the result is an Err.
  • An Err skips the success steps, but _apply still handles each later op, so a later "shout" still runs.

solve(start, ops) starts from Ok(start), applies the ops in order, and returns a dict with four keys:

  • "kind" is "ok" or "err".
  • "payload" is the value or the error string.
  • "unwrap_or" is unwrap_or(-1) on the final result.
  • "repr" is the repr of the final result.

With start=3 and ops=["halve", "double", "shout"], "halve" gives Err("odd"), "double" skips it, and "shout" gives Err("ODD"). So solve returns {"kind": "err", "payload": "ODD", "unwrap_or": -1, "repr": "Err(error='ODD')"}. An empty ops list returns Ok(start) unchanged.

Why an Ok[int] is not an Ok[object]

With the class Ok[T]: syntax, mypy infers variance from how the class uses T. Ok.unwrap_or takes a T and returns one, so Ok is invariant, and mypy rejects an Ok[int] where an Ok[object] is expected. Freezing the dataclass does not change that. In Err, E appears only in the frozen field and inside the callback type of map_err, so mypy infers Err as covariant. The pipeline uses Ok[int] | Err[str] throughout, so variance never matters there.