We can't find the internet
Attempting to reconnect
Something went wrong!
Attempting to reconnect
← Data Modelling and Invariants step 22 of 25
Runtime introspection after PEP 649: Field.type is not a type
Anything that builds a schema, a converter, an ORM mapping or a fixture factory
by walking fields() and switching on f.type is quietly broken. Not because
the idea is wrong — because Field.type is not a type. It is whatever the
annotation happened to be, and since PEP 649 that is three different kinds of
object depending on nothing you control.
Verified, in one class, on 3.14
@dataclass
class Fwd:
a: int
b: "list[str]"
c: Later # class defined further down the module
class Later: ...
| field |
f.type |
kind |
|---|---|---|
a |
<class 'int'> |
a real type |
b |
'list[str]' |
a str |
c |
ForwardRef('Later', is_class=True, owner=Fwd) |
a ForwardRef |
And the ForwardRef does not resolve retroactively: defining Later
afterwards does not turn f.type into the class. Whoever holds the Field
object holds a promise, not an answer.
Under from __future__ import annotations (PEP 563), everything in the
module is a string. So the same schema-generator sees different data depending on
one import line at the top of somebody else’s file. The usual failure is silent:
the isinstance(f.type, type) branch misses, control falls through to the
“unknown → treat as text” default, and you get a TEXT column where you wanted
INTEGER.
The deeper wart
Because annotations may be strings, dataclasses cannot always look at an
annotation to decide whether it is a ClassVar. Its fallback is a regex over
the string plus a lookup in the defining module’s globals. So this:
@dataclass
class Aliased:
CV = ClassVar # alias defined INSIDE the class body
n: "CV[int]" = 0
is not recognised. CV is not in module globals, the regex does not match
typing.ClassVar, and n becomes a real field — it is in fields(), in
__init__, in asdict(). Your resolver must report runtime reality, not the
intent.
Your task
Implement:
def resolve_field_types(cls: type[DataclassInstance]) -> dict[str, str]:
mapping each field name to a display string for its fully-resolved runtime
type. _display is written for you: a real class renders as its __name__,
anything else as str(...) (so int | None → "int | None",
ClassVar[int] → "typing.ClassVar[int]").
Resolution rules — write them as an exhaustive match over
type | str | ForwardRef, ending in case _: assert_never(annotation):
-
a
typeis already resolved; -
a
stris source: evaluate it; -
a
ForwardRefcarries its source in__forward_arg__: evaluate that.
Evaluate in a namespace built from the module globals plus vars(cls) —
the class body is where a CV = ClassVar alias lives, and without it the
Aliased case cannot resolve. (Here globals() is the module namespace. A
library that must resolve someone else’s class reaches it as
sys.modules[cls.__module__].__dict__ instead — same dictionary, found a
different way.)
When evaluation raises NameError, raise UnresolvedField(field_name, missing)
where missing comes from NameError.name. solve turns that into
{"__error__": "UnresolvedField:<field>:<missing>"}.
Why Broken is unresolvable. Its annotation names a class imported under
if TYPE_CHECKING:. mypy is perfectly happy; at runtime the name has never
existed. That is the single most common cause of this failure in real
codebases — and the reason a schema generator must produce a precise, named
error rather than falling back to a default.
Types. Field.type is loosely typed in the stubs, so the cast to
type[object] | str | ForwardRef is what makes assert_never meaningful: it
gives mypy a closed union to exhaust. If someone later adds a fourth annotation
kind, that assert_never is what turns it into a compile error instead of a
silent wrong branch.
Stuck?
Python reference solution
Sign in to attempt this problem and reveal the reference solution.