Topic 10.7
Types at Run Time: Annotations, get_type_hints and Validation
In one line
Hints are ordinary data that Python keeps, so a program can read them: __annotations__, typing.get_type_hints, get_origin and get_args. Dataclasses and Pydantic do exactly this, and so you can write a tiny validator yourself.
Think of it like this
A form with printed labels that a clerk reads aloud and checks your answers against. The labels (hints) are only paper until someone reads them. Python never reads them for itself, but libraries can walk up, read every label, and check your real data against it. That is how validation works.
Words you'll meet
New words in this topic, in plain English. Come back here whenever one feels fuzzy.
- Validation
- Checking that real data has the shape and types you expect, while the program runs.
- `get_type_hints`
- The function that returns an object's hints with names resolved into real types.
- Forward reference
- A hint written as a string because the thing it names is not defined yet.
- Deferred evaluation
- Not computing the hints when the definition runs, only when they are asked for.
- Coercion
- Converting a value to the expected type automatically, like "21.5" to 21.5 in Pydantic's default mode.
- JSON Schema
- A standard JSON description of a data shape, used by APIs and by LLM tool calling.
- Introspection
- A program examining its own objects: their attributes, hints and structure.
Step by step
01The hints are just a dictionary
A class stores each annotated field in Class.__annotations__. A field with a default value, like note: str | None = None, is in there too. This dictionary is all a library needs to know your shape.
class Order:
id: int
customer: str
tags: list[str]
note: str | None = None
print(Order.__annotations__)02Use get_type_hints, not the raw dictionary
For simple cases the two are the same. They differ when a hint is a string, as after from __future__ import annotations or when you write a forward reference. get_type_hints evaluates strings into real types, follows inheritance, and drops Annotated extras unless you pass include_extras=True.
03Take hints apart: get_origin and get_args
get_origin(list[str]) is list; get_args(list[str]) is (str,). For an unparameterised class like int, get_origin gives None, which tells your code "this is a plain class, use isinstance". With those two functions you can write a recursive checker.
04A tiny validator
The first example below reads the hints of Order and checks a dictionary against them: plain classes by isinstance, lists by checking each item, unions by trying each option. About twenty lines. It reports every problem instead of stopping at the first. Real validators (Pydantic) are the same idea with far more cases, written in fast compiled code.
05Dataclasses read, Pydantic enforces
The second example shows both on the same hints. The dataclass accepts a string for a float price; Pydantic converts or rejects it and can describe the model as a JSON Schema. In real projects, use Pydantic at the boundary: the edge of your program where data comes in from outside, such as a web request, a config file or an LLM reply. Inside, between your own trusted functions, plain classes and dataclasses are enough.
06The AI use: schemas for models
To let an LLM call your function, you send it a description: the tool's name and a JSON Schema of its arguments. Writing that by hand is error-prone. With Pydantic you write the arguments as a class and call model_json_schema(); when the model replies with arguments, you validate them with the same class. The hints are the single source of truth.
07Forward references and Python 3.14
Before 3.14, a hint naming a class that does not exist yet had to be quoted or the file needed from __future__ import annotations. In 3.14 hints are evaluated lazily when first needed, so the plain form works. Libraries should use get_type_hints or the new annotationlib module, never read raw values. The runnable example uses the future import (works on 3.13) to show hints turning into strings.
Try it yourself
- 1
Teach the validator a new type
In the first example, add a branch for
dicthints such asdict[str, int](origindict, two args). Check every key and every value. Test it with a new fieldprices: dict[str, int]. - 2
Change a Pydantic field
In the second example, make
celsiusanintand pass21.5. Pydantic rejects a float with a fractional part. Then run the schema again and see"type": "integer".
Code & diagrams
The checker walks the hints with get_origin and get_args. Missing fields without defaults are reported; fields with defaults may be left out.
Expected output
raw: {'id': <class 'int'>, 'customer': <class 'str'>, 'tags': list[str], 'note': str | None}
resolved: {'id': <class 'int'>, 'customer': <class 'str'>, 'tags': list[str], 'note': str | None}
[]
["id: expected <class 'int'>, got '1'", 'customer: missing', "tags: expected list[str], got ['a', 2]", 'note: expected str | None, got 5']Pydantic is available in the browser runner. The schema is what you would send to an LLM as the parameters of a tool.
Expected output
dataclass reads the hints: [('name', <class 'str'>), ('price', <class 'float'>)]
Item(name='tea', price='oops')
city='Pune' celsius=21.5 rainy=False
float
1 error
float_parsing - Input should be a valid number, unable to parse string as a number
{"celsius": {"title": "Celsius", "type": "number"}, "city": {"title": "City", "type": "string"}, "rainy": {"default": false, "title": "Rainy", "type": "boolean"}}
required: ['city', 'celsius']With the future import, hints are stored as strings. get_type_hints turns them back into types (here we pass globals() so the class Node can be found) and raises NameError for a name that does not exist.
Expected output
raw: {'value': 'int', 'next': 'Node | None'}
resolved: {'value': <class 'int'>, 'next': __main__.Node | None}
build raw: {'count': 'Missing', 'return': 'int'}
NameError: name 'Missing' is not definedOn 3.14 and later this runs without the future import or quotes. annotationlib is the module for reading annotations safely. Not runnable here (the browser has Python 3.13).
# Python 3.14 or later
class Node:
value: int
next: Node | None # no quotes needed: evaluated lazily, when first read
import annotationlib
print(annotationlib.get_annotations(Node, format=annotationlib.Format.STRING))
# {'value': 'int', 'next': 'Node | None'}Break it on purpose
Errors are the best teachers. Make each change, read the error, guess what went wrong, then reveal the answer.
Break #1
Forget the namespace for a forward reference
Resolve a hint that names a class created inside a function.
from typing import get_type_hints
def make():
class Local:
pass
class Holder:
item: "Local"
return Holder
print(get_type_hints(make()))Myth vs fact
Myth
Dataclasses check the types of their fields.
Fact
They don't: the example creates Item("tea", "oops"). They read hints to build methods only. Pydantic is the library that validates.
Myth
__annotations__ always contains real types.
Fact
Not when hints are strings (forward references or the future import). Use get_type_hints.
Myth
Validation with hints is the same as the type checker.
Fact
The checker works before running on the code; validation works while running on data from outside. You need both.
Pro corner
Extra depth for experienced readers. New to this? Skip it for now and come back later.
- ▸
In the browser runner this site uses, resolving string hints on a class with
get_type_hints(SomeClass)raisedNameErrorfor a name defined in the same file, untilglobals()was passed explicitly (the runner does not register the page's program as a module the waypython main.pydoes). Passing the namespace is harmless everywhere, so the examples here do it. - ▸
Pydantic compiles its validators from the hints when the class is created and runs them in Rust (the
pydantic-corepackage). That is why it is far faster than a Python loop like ours. It has a "strict" mode that refuses coercion such as "21.5" to float. - ▸
For LLM tool calling, the JSON Schema is usually wrapped in a vendor-specific envelope (name, description, parameters). Pydantic and the SDK helpers build that for you. Keep field names and
Field(description=...)text clear: the model reads them. - ▸
Validation errors from a model reply are best sent back to the model as text so it can fix its answer. This validate-then-retry loop is the heart of reliable structured output (Topic 16.6).
- ▸
How 3.14 changed this (PEP 649 and 749): annotations are no longer evaluated when the
deforclassline runs. The compiler stores a hidden function,__annotate__, and the first read of__annotations__calls it and caches the result. So a missing name raisesNameErrorat the first read, not at definition.annotationlib.get_annotations(obj, format=...)gives three views:VALUE(real objects, may raiseNameError),FORWARDREF(undefined names become placeholder objects instead of failing, the right choice for tools that must not crash) andSTRING(the source text).from __future__ import annotationsstill works in 3.14 but is no longer needed, and it stays supported for now. - ▸
Class
__annotations__lists only the fields declared in that class; inherited ones are merged byget_type_hints(it walks the MRO, the method resolution order, Topic 8.5).get_type_hintsdropsAnnotatedmetadata unlessinclude_extras=True. It evaluates strings witheval, so never pass it hints that came from untrusted text.
Remember this
- 1
Every class, function and module stores its hints in
__annotations__, a dictionary (Topic 4.4) from name to hint. A field is a variable declared inside a class (Topic 8.1). For a class,__annotations__holds the fields you declared with a colon, whether or not they have values. Libraries read this dictionary to learn "what fields does this class have, and of which type". - 2
Do not read
__annotations__directly in your own tools; use **typing.get_type_hints(obj)**. It returns the same mapping with strings turned into real types (for example"int"intoint), with inherited fields included (Topic 8.5). If a name cannot be found it raisesNameError. To help it find names, you can passglobals()as a second argument: the dictionary of every name defined at the top level of your file. - 3
To take a hint apart, use **
get_originandget_args**.get_origin(list[int])islistandget_args(list[int])is(int,). Forint | None(Topic 10.2) the origin is the union type and the args are(int, NoneType). A validator is a function that calls itself on the inner parts (a recursive function, Topic 5.8) using these two. - 4
Dataclasses read the annotations to create
__init__,__repr__and__eq__(Topic 8.8). They use the types only as information:Item("tea", "oops")creates an item with a string price. They are a code generator, not a validator. - 5
Pydantic (Topic 15.4) reads the same hints and *does* validate:
Weather(city="Pune", celsius="21.5")converts the string to the float 21.5, andcelsius="warm"raisesValidationErrorwith a precise message. It can also output a JSON Schema from the hints: a written description, in JSON, of what fields a piece of data has and their types. - 6
That schema is what you send to an LLM API to describe a tool's parameters or the shape of a structured answer (Topics 16.6 and 16.10). One class gives you the Python object, the validation, and the contract for the model.
- 7
Hints can be strings. Writing
"Node | None"in quotes (a forward reference) lets a class mention itself before it exists, andfrom __future__ import annotationsmakes every hint in the file a string. In Python 3.14 this became the default behaviour in a better form. Annotations are worked out lazily, only when someone asks for them, so forward references just work. - 8
That is why code that reads hints should always go through
get_type_hints(or the 3.14annotationlib). Do not assume the raw values are classes.
Explain it without notes
Where does Python store type hints, and how should you read them?
What is the difference between how a dataclass and Pydantic use the same hints?
What is a forward reference and what changed in Python 3.14?
How do type hints help with LLM tool calling?
Why can reading __annotations__ fail or mislead, and what do you use instead? What changes on 3.14?
Practice
Print the resolved type hints (use get_type_hints) of a function def f(a: int, b: list[str] = []) -> bool.
Write field_names(cls) that returns the list of annotated field names of a class, and call it for a small class.
Create a Pydantic model User with name: str and age: int, build one from age="42", and print user.age + 1.
Trade-offs
- ↔
Write your own validator or use Pydantic. A twenty-line validator teaches the idea; for real work Pydantic handles hundreds of edge cases and is fast.
- ↔
Strict versus coercing validation. Coercion ("21.5" becomes 21.5) is convenient for web forms and LLM output; strict mode is safer when the exact type matters.
- ↔
Hints at run time have a cost. Reading and validating hints on every call is slower than trusting them. Validate at the boundary, then pass trusted objects inside.
Done when you can
Done when you can read a class's hints with
get_type_hints.Done when you can take a hint apart with
get_originandget_args.Done when you can explain how dataclasses and Pydantic use hints differently.
Done when you can explain a forward reference and the 3.14 change.
Done when you can describe how one Pydantic class gives an LLM tool schema and validation.