We can't find the internet
Attempting to reconnect
Something went wrong!
Attempting to reconnect
← Data Modelling and Invariants step 23 of 25
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.
Stuck?
Python reference solution
Sign in to attempt this problem and reveal the reference solution.