Skip to content

← Data Modelling and Invariants step 10 of 25

Hard Framework

dataclass_transform (PEP 681): making your own decorator type-checkable

Every codebase past a certain size grows a house decorator. @entity, @value, @record — something that bundles frozen=True, slots=True, kw_only=True plus whatever registration or auditing the project needs, so nobody has to remember the four flags. It is a good instinct.

Written naively, it also blinds the type checker to your entire domain layer. Once the class goes through a plain def entity(cls): ..., mypy no longer knows it is a dataclass: no synthesized __init__, no keyword-only enforcement, no read-only fields. Every domain object silently degrades to Any-ish, in the one part of the codebase where types matter most.

PEP 681 is the fix

@typing.dataclass_transform() marks a decorator (or base class, or metaclass) as “whatever I return behaves like a dataclass”. Type checkers then synthesize the same members they would for @dataclass.

Parameters:

  • eq_default, order_default, kw_only_default — what the checker should assume when your decorator is used with no arguments;
  • frozen_default — added in 3.12;
  • field_specifiers — the tuple of callables that count as field declarations.

Checkers recognise a fixed set of parameter names on a field specifier: init, default, default_factory, factory, kw_only, alias. Name yours anything else and the checker ignores it (which is fine — secret= below is for your runtime, not the checker’s).

Verified behaviour: a decorator declared @dataclass_transform(frozen_default=True, kw_only_default=True) whose body is literally ... is enough to make M(1) report “Too many positional arguments” and m.a = 2 report a read-only assignment error. The checker believes the declaration, not the implementation.

Which is also the caveat. mypy assumes such classes have __dataclass_fields__ whether or not the runtime provides it, so is_dataclass() narrowing can be statically true and runtime false. The declaration is a promise you have to keep.

The ordering trap

slots=True returns a new class object. So this is wrong:

def entity(cls):
    REGISTRY[cls.__name__] = cls                     # the OLD class
    return dataclass(frozen=True, slots=True)(cls)   # a different object

Your registry now holds a class that is not the one the module-level name refers to, is not the one isinstance will match, and has no __slots__. Transform first, register the result.


Your task

  1. entity_field(*, default=..., default_factory=..., secret=False) — returns a dataclasses.field(...) carrying metadata={"secret": secret}, forwarding whichever of default / default_factory was supplied (using MISSING as the “not supplied” sentinel, exactly as field() does).
  2. entity — decorate it with @dataclass_transform(frozen_default=True, kw_only_default=True, field_specifiers=(entity_field,)), apply dataclass(frozen=True, slots=True, kw_only=True), and register the result in REGISTRY under its __name__.
  3. solve(target, name) returning:
key value
"registry" sorted(REGISTRY)
"registry_matches" REGISTRY[target] is type(obj)
"repr" repr(obj)
"frozen_error" "FrozenInstanceError"
"positional_error" "TypeError"
"secret_fields" field names whose metadata says secret
"has_dict" hasattr(obj, "__dict__")

registry_matches is the assertion that catches the ordering trap: it is False for every implementation that registers before transforming. has_dict is False only if slots=True really was applied.

Types. entity_field returns Any on purpose — a field specifier has to be assignable to a field of any declared type, and Any is the only annotation that permits it. That is the same trick dataclasses.field itself uses in typeshed. Everything else in the module stays fully typed.