Skip to content

← The Type System as a Design Tool step 11 of 24

Medium Primitives

A generic Result[T, E]

Generic classes are what make repo.get(user_id) return a User instead of an Any — at every call site, forever, without a cast. Repositories, result wrappers, caches, connection pools, paginated responses: the generic container is the single highest-leverage type in most application codebases, because one correct annotation propagates type information through hundreds of call sites that nobody has to annotate individually.

PEP 695 (3.12) replaced the ceremony:

# before
T = TypeVar("T")
E = TypeVar("E")
class Result(Generic[T, E]): ...

# now
class Result[T, E]: ...

The runtime enforces the migration rather than allowing both: writing class A[T](Generic[T]) is a hard TypeError at import time. There is no gradual mode here — a class is either PEP 695 or it is not.

The task

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]": ...


def safe_div(numerator: int, denominator: int) -> Result[int, str]: ...
def solve(values: list[int], divisor: int) -> tuple[list[str], list[bool], list[int]]: ...

unwrap raises ValueError on an err. unwrap_or returns the default instead. map applies f to an ok value and short-circuits on an err, carrying the error through unchanged — that is the entire point of the type. Note map declares its own type parameter [U]: the method is generic independently of the class.

safe_div returns Result.err("division by zero") for a zero divisor and Result.ok(numerator // denominator) otherwise — Python’s floor division, so -3 // 2 is -2 and 10 // -2 is -5.

solve builds one Result per value and returns three lists: each result mapped through lambda n: f"<{n}>" then unwrap_or("n/a"); each is_ok(); and each unwrap_or(-1).

The interesting design problem

unwrap must return a T, and the err case has no T to return. The naive storage — self._value: T | None — makes unwrap unimplementable without a cast, because T might itself include None and the checker cannot tell an absent value from a present None.

The idiomatic fix is to store presence in the shape rather than the value:

self._value: tuple[T] | tuple[()]

A one-tuple means “present”, the empty tuple means “absent”, and mypy narrows the union on len(self._value) == 1. No sentinel, no Optional, no cast. This trick — encoding an option in a container’s arity — is worth having in your pocket; it is exactly how you write a total first() or a total pop().

Types

Under --strict, --disallow-any-generics rejects a bare Result annotation: def f() -> Result is an error, Result[int, str] is not. That is the flag that stops a generic class from quietly degrading into an untyped one.

Two static facts the design is supposed to guarantee, worth checking in your editor before you submit:

  • Result.ok(1).map(str).unwrap() reveals str, not Any and not int.
  • Result.ok(1).unwrap() + "x" is a type error, because unwrap returns int.

Generic must not appear anywhere in your submission, and neither may a module-scope TypeVar.

Forward references inside the class body do need quoting — Result is not bound until the class statement finishes executing, so -> "Result[U, E]" is required in 3.12. (PEP 649’s deferred annotations change this in 3.14, but not for a module targeting 3.12.)

Loading visualization…