Skip to content

← Data Modelling and Invariants step 16 of 25

Hard Framework

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 TypeError if a and b are 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.