We can't find the internet
Attempting to reconnect
Something went wrong!
Attempting to reconnect
← Data Modelling and Invariants step 16 of 25
Writing generic code over dataclasses under --strict
Every serializer, ORM mapper, fixture factory, audit-log helper and diff tool you
will ever write is a function generic over dataclasses. It is also the place
where teams first give up on --strict and start sprinkling # type: ignore,
because the stdlib’s own narrowing does not do what it looks like it does.
There is no public Dataclass type
@dataclass is a decorator, not a base class. There is no ABC, no Protocol in
typing, nothing to bound a TypeVar with. What exists is
_typeshed.DataclassInstance — a stub-only Protocol declaring
__dataclass_fields__: ClassVar[dict[str, Field[Any]]]. It has no runtime
existence at all, so it can only be imported under if TYPE_CHECKING:, and every
annotation mentioning it has to be a string (or the module needs
from __future__ import annotations).
The narrowing trap
is_dataclass is stubbed with three TypeIs overloads. The one that fires for
an object argument narrows to the union:
DataclassInstance | type[DataclassInstance]
which is correct — is_dataclass(SomeClass) really is True — and useless:
if is_dataclass(o):
asdict(o) # error: Argument 1 has incompatible type
# "DataclassInstance | type[DataclassInstance]"
The documented fix is the one the stdlib uses on itself:
if is_dataclass(obj) and not isinstance(obj, type):
... # now narrowed to DataclassInstance
Wrap that in your own TypeIs predicate once, and every call site downstream is
clean. Note it must be TypeIs, not TypeGuard: TypeGuard narrows only the
positive branch, so if not is_dataclass_instance(o): raise leaves o as
object afterwards and you are back where you started.
Your task
Two functions, and zero # type: ignore anywhere.
def is_dataclass_instance(obj: object) -> TypeIs[DataclassInstance]: ...
def diff[T: DataclassInstance](a: T, b: T) -> dict[str, tuple[object, object]]: ...
diff returns {field_path: (old, new)} for every differing field:
-
raise
TypeErrorifaandbare not the same concrete class — a subclass is not close enough, because their field sets differ; -
skip fields with
compare=False— if the field is not part of the value’s identity, a change in it is not a change; -
when both sides of a field are dataclass instances of the same class, recurse
and prefix the child keys, producing dotted paths like
"inner.x"; -
otherwise compare with
!=and record(old, new)as a tuple.
solve(case) looks up a pair in CASES (typed tuple[object, object], so you
must narrow before you can call diff at all) and returns
{"error": "", "changes": {...}}, or {"error": "TypeError", "changes": {}}.
The "class_arg" case passes the class object Outer on both sides. That is
the whole point of the not isinstance(obj, type) half of the predicate: without
it, is_dataclass says yes, fields() happily returns the field list, and
getattr(Outer, "inner") raises AttributeError — a confusing error a long way
from the cause. With it, you get a clean TypeError at the boundary.
PEP 695 note. def diff[T: DataclassInstance](...) works at runtime even
though DataclassInstance does not exist there: PEP 695 evaluates TypeVar bounds
lazily, only when __bound__ is read. That laziness is what makes
stub-only bounds usable in real code.
Stuck?
Python reference solution
Sign in to attempt this problem and reveal the reference solution.