Skip to content

← Data Modelling and Invariants step 23 of 25

Hard Framework

Generic dataclasses, Self, and the covariance question

Generic containers and result types are where most teams first hit a variance error, fail to understand it, and reach for # type: ignore. A frozen dataclass is the best possible place to learn it, because it makes the thing variance is about — mutability — explicit in one decorator argument.

Why mutability forces invariance

@dataclass
class Box[T]:
    item: T

Is Box[int] usable where Box[object] is expected? No — and here is the counterexample, in three lines:

b: Box[int] = Box(1)
o: Box[object] = b        # if this were allowed...
o.item = "not an int"     # ...this would type-check...
b.item + 1                # ...and this would blow up at runtime.

The writable field is what breaks it. A mutable generic dataclass must be invariant, and mypy will tell you so.

Make it frozen=True and the counterexample evaporates: there is no o.item =. Covariance is now semantically safe — but you still have to declare it, because the checker reasons from the declaration, not from the decorator. Two ways: a read-only Protocol, or PEP 695’s [T], whose variance mypy infers from usage. (Note: TypeVar(..., infer_variance=True) is rejected by mypy 2.3 even though CPython has supported it since 3.12 — PEP 695 syntax is the only route to inferred variance.)

Self versus a named return type

Self means “the type of the actual receiver, including subclasses”. Use it when a method returns the same object or the same shape: Err.map returns self, so Self is exactly right and a subclass of Err keeps its own type through the call. Use a concrete return type when the shape genuinely changes: Ok.map returns Ok[U] with a different parameter, which Self cannot express.

The Never trick

Err.map never calls its function. But it must still accept whatever the caller passes — Callable[[int], str], Callable[[Order], Receipt], anything. Annotate the parameter Callable[[Never], object]: parameters are contravariant, so a callable taking any type is assignable to one taking Never (nothing is a subtype of everything, so nothing can be passed). It is the honest type for a function that will never be invoked, and it needs no Any.

One known mypy limitation

dataclasses.replace cannot return a differently-parametrised generic: replace(box_of_int, item="s") will not give you a Box[str]. If you need to change the parameter, construct a new object — which is what map does here.


Your task

Implement the two halves of Result[T, E], both @dataclass(frozen=True, slots=True), using PEP 695 syntax and no Any, no # type: ignore:

method on Ok[T] on Err[E]
map(fn) apply to the value → Ok[U] ignore → Self
map_err(fn) ignore → Self apply to the error → Err[F]
and_then(fn) fn(value)Ok[U] | Err[F] ignore → Self
unwrap_or(default) the value the default

Then _apply(result, op) dispatching one of five ops ("double", "dec", "halve", "guard", "shout") with a match, raising ValueError(f"unknown op: {op!r}") otherwise; and solve(start, ops) folding the ops over Ok(start) and returning "kind" ("ok"/"err"), "payload" (the value or the error), "unwrap_or" (with default -1) and "repr".

Watch the shape of the calls in _apply: result is a union, so every call must type-check against both members and the results must unify back to Result[int, str]. That is the property that makes a Result type worth having — the short-circuit is enforced by the type system, not by a convention, and an Err cannot accidentally be treated as an Ok anywhere downstream.

Ok(start) must be assignable where Result[int, str] is expected, with no annotation gymnastics at the call site.