Command Palette

Search for a command to run...

Hectal
PHASE 10Intermediate ~27 min· topic 7 of 9

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.

main.pywhole filepython
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.

The AI use: schemas for modelsdiagram
Rendering diagram…

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. 1

    Teach the validator a new type

    In the first example, add a branch for dict hints such as dict[str, int] (origin dict, two args). Check every key and every value. Test it with a new field prices: dict[str, int].

  2. 2

    Change a Pydantic field

    In the second example, make celsius an int and pass 21.5. Pydantic rejects a float with a fractional part. Then run the schema again and see "type": "integer".

Code & diagrams

A tiny validator built on get_type_hints New tab

The checker walks the hints with get_origin and get_args. Missing fields without defaults are reported; fields with defaults may be left out.

Sign in to run this example in your browser.

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']
Dataclass reads hints, Pydantic validates and makes a schema New tab

Pydantic is available in the browser runner. The schema is what you would send to an LLM as the parameters of a tool.

Sign in to run this example in your browser.

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']
Hints as strings and forward references New tab

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.

Sign in to run this example in your browser.

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 defined
Python 3.14: annotations are evaluated only when needed Python 3.14+python

On 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.

main.pywhole filepython
from typing import get_type_hints

def make():
    class Local:
        pass

    class Holder:
        item: "Local"

    return Holder

print(get_type_hints(make()))
terminal
$ python main.py
── what you'll see ──
Traceback (most recent call last):
File "main.py", line 12, in <module>
print(get_type_hints(make()))
~~~~~~~~~~~~~~^^^^^^^^
File "/lib/python313.zip/typing.py", line 2461, in get_type_hints
value = _eval_type(value, base_globals, base_locals, base.__type_params__)
File "/lib/python313.zip/typing.py", line 474, in _eval_type
return t._evaluate(globalns, localns, type_params, recursive_guard=recursive_guard)
~~~~~~~~~~~^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "/lib/python313.zip/typing.py", line 1081, in _evaluate
eval(self.__forward_code__, globalns, localns),
~~~~^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "<string>", line 1, in <module>
NameError: name 'Local' is not defined. Did you mean: 'locals'?

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) raised NameError for a name defined in the same file, until globals() was passed explicitly (the runner does not register the page's program as a module the way python main.py does). 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-core package). 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 def or class line runs. The compiler stores a hidden function, __annotate__, and the first read of __annotations__ calls it and caches the result. So a missing name raises NameError at the first read, not at definition. annotationlib.get_annotations(obj, format=...) gives three views: VALUE (real objects, may raise NameError), FORWARDREF (undefined names become placeholder objects instead of failing, the right choice for tools that must not crash) and STRING (the source text). from __future__ import annotations still 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 by get_type_hints (it walks the MRO, the method resolution order, Topic 8.5). get_type_hints drops Annotated metadata unless include_extras=True. It evaluates strings with eval, so never pass it hints that came from untrusted text.

Remember this

  1. 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. 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" into int), with inherited fields included (Topic 8.5). If a name cannot be found it raises NameError. To help it find names, you can pass globals() as a second argument: the dictionary of every name defined at the top level of your file.

  3. 3

    To take a hint apart, use **get_origin and get_args**. get_origin(list[int]) is list and get_args(list[int]) is (int,). For int | 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. 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. 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, and celsius="warm" raises ValidationError with 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. 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. 7

    Hints can be strings. Writing "Node | None" in quotes (a forward reference) lets a class mention itself before it exists, and from __future__ import annotations makes 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. 8

    That is why code that reads hints should always go through get_type_hints (or the 3.14 annotationlib). Do not assume the raw values are classes.

Explain it without notes

01

Where does Python store type hints, and how should you read them?

02

What is the difference between how a dataclass and Pydantic use the same hints?

03

What is a forward reference and what changed in Python 3.14?

04

How do type hints help with LLM tool calling?

05

Why can reading __annotations__ fail or mislead, and what do you use instead? What changes on 3.14?

Practice

01

Print the resolved type hints (use get_type_hints) of a function def f(a: int, b: list[str] = []) -> bool.

02

Write field_names(cls) that returns the list of annotated field names of a class, and call it for a small class.

03

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_origin and get_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.