Skip to content

← Data Modelling and Invariants step 22 of 25

Hard Framework

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 type is already resolved;
  • a str is source: evaluate it;
  • a ForwardRef carries 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.