Skip to content

← Modelling Data with Dataclasses step 27 of 28

Hard Framework

Resolve Dataclass Field Types in Python 3.14

This exercise needs Python 3.14. The detail it covers matters most to tools that read field types, like the codec in Build a JSON Codec for Dataclasses.

A tool that reads f.type expects a class such as int. In Python 3.14 it can also find a string or a ForwardRef. Which one depends on how you wrote the annotation, and on whether its names existed when @dataclass ran:

from dataclasses import dataclass, fields


@dataclass
class Forward:
    c: Later
    d: "Later"


class Later:
    pass


print([type(f.type).__name__ for f in fields(Forward)])  # ['ForwardRef', 'str']

Python 3.14 evaluates an annotation only when code asks for it. When @dataclass asks, Later does not exist yet, so the decorator stores a forward reference, a ForwardRef object that holds the name "Later". A quoted annotation stays a string. Defining Later afterwards does not change what the dataclass stored, so your resolver evaluates the names itself, once Later exists.

What to write

Fill in resolve_field_types(cls) -> dict[str, str]. It maps each field name to a display string for the field's type.

  • Walk dataclasses.fields(cls), so the result has exactly the dataclass's fields.
  • Build the namespace by copying this module's globals(), then updating the copy with vars(cls). A name defined in the class overrides a global one.
  • Use a match on the annotation. A class, case type(), stays as it is, without eval. A string, case str(), goes to eval with the namespace. For a ForwardRef, case ForwardRef(), evaluate its __forward_arg__ string the same way.
  • Pass each result to the supplied _display. It turns a class into its __name__ and any other object into str(object).
  • Do not change the class or its Field objects.

If eval raises NameError, raise UnresolvedField(field_name, missing) from it, so the NameError becomes the cause. missing is exc.name, or the source string if exc.name is None. Let any other error propagate. solve catches UnresolvedField and returns {"__error__": "UnresolvedField:<field>:<missing>"}.

Expected results

Target What it shows Result
Plain Annotations that are classes {"a": "int", "b": "str"}
Stringy Quoted generic and union types {"b": "list[str]", "c": "int | None"}
Forward A forward reference and a quoted name, both for a later class {"c": "Later", "d": "Later"}
AllStrings Quoted simple and generic types {"x": "int", "y": "dict[str, int]"}
Broken A name that exists only for mypy {"__error__": "UnresolvedField:d:NeverDefined"}
Aliased A ClassVar alias that the decorator did not recognize {"n": "typing.ClassVar[int]"}

Why Aliased has a field n

Aliased defines CV = ClassVar in its class body and annotates n with the string "CV[int]". The decorator recognizes a quoted ClassVar only through names it finds in the class's module, and CV is not there. So n becomes a field. Your resolver finds CV through vars(cls) and shows typing.ClassVar[int]. Still report n, because resolving the annotation now does not change the fields that the decorator made. The starter builds Aliased with type(), because mypy rejects CV[int] in a class body written out by hand.

Type checking

  • cast("type[object] | str | ForwardRef", f.type) tells mypy which three forms to expect. End the match with case _: assert_never(annotation), so mypy reports a missing case if the union grows.
  • A cast checks nothing at runtime. If f.type holds another kind of object, such as an evaluated list[str], the match reaches assert_never, which raises AssertionError.
  • DataclassInstance is a type that exists only for the type checker and means "any dataclass instance". That is why the starter imports it under if TYPE_CHECKING: and writes it in quotes.

Going further

  • For a real tool, use typing.get_type_hints or the annotationlib module that Python 3.14 added. They also handle inherited fields, other modules and quoted names nested inside types, which this resolver does not.
  • eval runs code. Evaluate annotations only from code you trust.
  • With from __future__ import annotations at the top of a module, Python stores every annotation in that module as a string.

See annotationlib and get_type_hints for the standard tools.