Skip to content

← Precise Type Hints with Literals and Generics step 12 of 26

Medium Primitives

Build a Generic Result Class

Some functions report a failure by returning it instead of raising it. The caller gets back a Result that holds either a value or an error, and checks which one before using it. Build Result[T, E]. It is a generic class, which is a class with a type parameter, like list[T]. T is the type of a success value, and E is the type of an error.

number = Result[int, str].ok(7)
number.map(lambda n: f"<{n}>").unwrap()        # "<7>"
number.unwrap()                                # 7
Result[int, str].err("missing").unwrap_or(-1)  # -1
Result[None, str].ok(None).is_ok()             # True
Result[None, str].ok(None).unwrap()            # None

What to write

The starter uses the older spelling from the article "Generic Functions from Zero", class Result(Generic[T, E]) with two TypeVar lines. Rewrite it as class Result[T, E]:, and delete Generic and the TypeVar lines. Then add these methods:

from collections.abc import Callable

class Result[T, E]:
    @classmethod
    def ok(cls, value: T) -> "Result[T, E]": ...
    @classmethod
    def err(cls, error: E) -> "Result[T, E]": ...
    def is_ok(self) -> bool: ...
    def unwrap(self) -> T: ...
    def unwrap_or(self, default: T) -> T: ...
    def map[U](self, f: Callable[[T], U]) -> "Result[U, E]": ...

map declares its own type parameter U, because f can change the success type. The error type E stays the same.

Create results only through ok or err. Each result holds either a value or an error, never both.

Method On a success On an error
is_ok() Return True. Return False.
unwrap() Return the value. Raise ValueError.
unwrap_or(default) Return the value. Return default.
map(f) Call f once on the value, and return a new success that holds its result. Do not call f. Return a new result that holds the same error.
  • map leaves the original result unchanged. If f raises, let the exception propagate.
  • None, 0, False and "" are valid success values. ok(None) is a success, and its unwrap() returns None.
  • Keep the quotes around "Result[T, E]" in the return types. Python 3.12 evaluates each annotation when it defines the method, and the name Result does not exist yet at that point.
  • Write both type arguments when you create a result, as in Result[int, str].ok(7). A bare Result.ok(7) gives mypy no error type, so mypy infers Result[int, Never].

Store the value so that None still counts

A single value: T | None attribute cannot tell ok(None) apart from an error. Store each side as a tuple instead. self._value holds (value,) on a success and () on an error, with the type tuple[T] | tuple[()]. self._error works the same way, with the type tuple[E] | tuple[()]. After if len(self._value) == 1:, mypy narrows self._value to tuple[T], so self._value[0] has type T. Another design passes too, as long as it keeps ok(None) and an error apart.

What solve returns

def safe_div(numerator: int, denominator: int) -> Result[int, str]:
    ...

def solve(values: list[int], divisor: int) -> tuple[list[str], list[bool], list[int]]:
    ...
  • safe_div returns an error that holds "division by zero" when denominator is zero.
  • Otherwise it returns a success that holds numerator // denominator. Floor division rounds down, toward negative infinity, so -3 // 2 == -2 and 7 // -2 == -4.

solve calls safe_div(value, divisor) for each value, and returns three lists in this order:

  1. Each result mapped with f"<{n}>", then unwrapped with the default "n/a".
  2. Each result's is_ok().
  3. Each result unwrapped with the default -1.

Empty values gives ([], [], []), even when divisor is zero.

solve([10, 7, -3], 2)  # (["<5>", "<3>", "<-2>"], [True, True, True], [5, 3, -2])
solve([10], 0)         # (["n/a"], [False], [-1])

Going further

from __future__ import annotations at the top of the file also fixes the self-reference, because it makes Python store every annotation as a string. Python 3.14 evaluates annotations only when code reads them, so the quotes are optional there. See Python's type parameter reference and the mypy guide to generics.