We can't find the internet
Attempting to reconnect
Something went wrong!
Attempting to reconnect
← Data Modelling and Invariants step 10 of 25
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
-
entity_field(*, default=..., default_factory=..., secret=False)— returns adataclasses.field(...)carryingmetadata={"secret": secret}, forwarding whichever ofdefault/default_factorywas supplied (usingMISSINGas the “not supplied” sentinel, exactly asfield()does). -
entity— decorate it with@dataclass_transform(frozen_default=True, kw_only_default=True, field_specifiers=(entity_field,)), applydataclass(frozen=True, slots=True, kw_only=True), and register the result inREGISTRYunder its__name__. -
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.
Stuck?
Python reference solution
Sign in to attempt this problem and reveal the reference solution.