skip to content

How does typing.get_type_hints resolve a class's hints differently from a function's?

level: seniorimportance: should knowfreq 40%

answer

  1. A function has one; a class has many
  2. The resolver walks the hierarchy
  3. Whose module globals resolve which entries
  4. Own annotations versus inherited ones
  5. Reverse MRO, so the subclass wins

basics

~20 s

For a class, typing.get_type_hints walks the MRO from the base end, merging each class's own annotations and evaluating each against its defining module's globals. For a function it evaluates one annotation set against the function's globals.

solid answer

~50 s

A function has one annotation set and one namespace: `get_type_hints` evaluates it against `func.__globals__`. A class has as many as its MRO is long. `get_type_hints(cls)` walks `cls.__mro__` in reverse, and for each class takes that class's *own* annotations and evaluates them against `sys.modules[base.__module__].__dict__` — the globals of the module where that base was defined — merging results as it goes, so the most derived class wins any name it re-annotates. Two consequences matter in practice. First, the result contains inherited attribute hints that `cls.__annotations__` does not: a class's own annotations dict holds only what its body declared. Second, a base class living in a library resolves its forward references against that library's imports, not yours, which is why inheriting from a library base does not force you to import its dependencies. Method annotations are not included; call `get_type_hints` on the method itself.

code

python · 13 lines
python
from typing import get_type_hints

class BaseEvent:
    received_at: "float"

class PaymentEvent(BaseEvent):
    amount: int

print(PaymentEvent.__annotations__)
# {'amount': <class 'int'>}

print(get_type_hints(PaymentEvent))
# {'received_at': <class 'float'>, 'amount': <class 'int'>}

go deeper

for a junior

Know that a class's own annotations shows only what its body declared, and that asking typing.get_type_hints for the class is what gives you the inherited attribute hints as well.

for a middle

Explain the mechanics: a reverse walk of the MRO, each class's annotations evaluated against its own defining module's globals, merged so the most derived declaration wins.

for a senior

Show why the per-class namespace rule matters in practice - a library base resolving with its own imports - and why you resolve once at class registration rather than per instance.

for a principal

Own the contract your framework places on user classes: whether declared attribute hints are the source of truth, what happens to dynamically set attributes, and whether field ordering is pinned rather than inherited from the merge.

## One namespace versus one namespace per class For a function the model is simple: the annotations belong to that function, and they are evaluated against `func.__globals__`, the module dict where the `def` appeared. One set of annotations, one namespace. A class is a hierarchy, and `typing.get_type_hints` treats it as one. It iterates `cls.__mro__` in reverse — from `object` down to the class you asked about — and for each entry it collects that class's *own* annotations and evaluates them against the globals of the module where **that** class was defined, obtained from `sys.modules` via the class's `__module__`. Each round updates the accumulating dict, so a subclass that re-annotates an inherited attribute overwrites the base's entry, matching the attribute-lookup semantics a reader expects. The class's own namespace is also available while resolving its annotations, so a helper class defined inside the class body can be named in an annotation there. ## Consequence one: inherited hints appear `cls.__annotations__` is own-annotations-only. A subclass that declares nothing gets an empty dict, not its parent's. That is deliberate: before 3.10, an un-annotated class would appear to have its base's annotations purely through ordinary attribute lookup, which produced a famous class of bugs in code that iterated a class's fields. In 3.10 classes and modules gained a lazily-created empty annotations dict so that access no longer falls through to a base. So if you want "every annotated attribute this class has, including inherited ones", `get_type_hints(cls)` is the API, and reading `__annotations__` in a loop over the MRO is you re-implementing it — usually without the per-class globals, which is where the hand-rolled version breaks. ## Consequence two: each base resolves in its own module This is the part that stops being academic the moment you inherit from a class you did not write. Suppose a base class declares an attribute annotated with a name that only its own module imports, perhaps quoted, perhaps a forward reference. If resolution used *your* module's globals, subclassing it would fail unless you imported names you have no reason to know about. Because each class's annotations are evaluated in its defining module, the base resolves with its own imports and your subclass resolves with yours, and neither leaks into the other. The same rule explains a failure people find surprising: if a base's annotation names something under a guarded import in *its* module, no amount of importing that name in your module fixes it, because your globals are never consulted for that base's entries. ## What is not included Methods. `get_type_hints(cls)` returns the class-body attribute annotations, not the signatures of the functions defined in the body — those are annotations *of the functions*, and you get them by calling `get_type_hints` on the method. Likewise, an attribute assigned in `__init__` without an annotation in the class body is invisible to this API, because there is no annotation to resolve; runtime attribute discovery is a different problem. Entries wrapped in `ClassVar` are returned as-is, wrapper included; deciding what `ClassVar` means for your consumer is your job, not the resolver's. `Annotated` metadata is stripped unless you pass `include_extras=True`, exactly as for a function. ## Practical guidance This merge is why so much runtime machinery — record types, schema builders, injection containers, serializers — is built on class annotations rather than on instance inspection: one call gives you the complete, resolved, inheritance-aware picture of the declared attributes. Two cautions. First, cost: resolving a deep hierarchy evaluates every base's annotations on every call, and nothing is cached, so do it once when the class is registered rather than per instance. Second, ordering: the returned dict is in MRO-merge order, base entries first, with a re-annotated name keeping the position it first appeared at while taking the derived value. If your consumer cares about declaration order — a positional constructor, a serialized field order — do not assume it matches the subclass body's order, and pin the ordering you need explicitly rather than inheriting whatever the merge produced. Finally, remember what you are asking for. `get_type_hints(cls)` answers "what are the declared attribute types of this class, resolved". It does not answer "what attributes will an instance have", and treating the two as the same is how a serializer ends up silently dropping everything that was set dynamically.

  • Does typing.get_type_hints on a class include the annotations of its methods?
    No. It returns the attribute annotations declared in the class bodies across the MRO. A method's parameter and return hints belong to that function object, so you call get_type_hints on the method to get them. An attribute only assigned inside __init__, with no class-body annotation, does not appear at all - there is nothing to resolve.
  • Which module's globals resolve a base class's quoted annotation?
    The module where that base class was defined, looked up through its __module__ in sys.modules - never the subclass's module. That is why inheriting from a library base does not require you to import the library's own dependencies, and equally why importing a missing name into your module cannot fix a base whose annotation fails to resolve.
  • What happens when a subclass re-annotates an attribute the base already annotated?
    The subclass wins. The merge walks the MRO in reverse, base first, so the most derived entry overwrites earlier ones - matching what attribute lookup would give you. The key keeps the position it first took in the merged dict, which matters if your consumer treats the ordering as declaration order.

saying these in an interview costs you the question

  • Assumes a subclass's __annotations__ already includes inherited entries
  • Thinks every base resolves against the subclass's module globals
  • Expects method signatures in the class-level result
  • Believes the merge lets base classes overwrite the subclass
  • Treats the resolved hints as the instance's actual attributes

context