Python for AI Engineers

◆ Chapter 08

Type hints and data validation with Pydantic (the GenAI engineer's superpower)

Type hints and Pydantic — the validation layer that turns messy LLM output into typed, trustworthy data. The GenAI superpower.

~2,493 words · chapter 8 of 15

If you read enough real agent code — LangChain tools, FastAPI endpoints, OpenAI's structured-output helpers — you'll notice the same shape everywhere: a class describing exactly what data should look like, handed to an LLM, then used to validate whatever the LLM sends back. That class is almost always a Pydantic model. This chapter builds you up to reading and judging that pattern with confidence.

8.1 Type hints: gradual typing on a dynamic language

Python is dynamically typed — a variable's type is checked at runtime, never declared up front. But since 3.5 it's also gradually typed: you can annotate types, and tools will check them, while the interpreter happily ignores them.

# A bare variable annotation (no value needed, though usually you give one)
model_name: str = "claude-opus-4"
temperature: float = 0.7
max_tokens: int | None = None   # may be an int, or None

# Function signatures are where hints earn their keep
def truncate(text: str, limit: int = 100) -> str:
    return text[:limit]

print(truncate("hello world", 5))   # => hello

JS → TS/Python: This is exactly TypeScript's annotation syntax rearranged. const n: string = "x" becomes n: str = "x"; function f(a: number): string becomes def f(a: int) -> str:. The -> str after the parens is the return type — think of it as the : string that TS puts in the same spot.

The critical difference from TypeScript: TS strips types at compile time, so at runtime both languages have zero type information enforced. But TS forces you through a compiler that refuses to emit broken code. Python has no such gate — you can run code that violates every annotation:

def add(a: int, b: int) -> int:
    return a + b

print(add("no", "check"))   # => nocheck  ← runs fine! hints are decorative

The hint is a lie the runtime won't catch. Hold onto this — it's the entire reason Pydantic exists.

8.2 The built-in generics and special forms

Modern Python (3.9+, and you're on 3.12+) uses the built-in container types directly as generics — no more importing List, Dict from typing.

from typing import Any, Literal, Callable, Optional, Union, TypedDict, Annotated

tags: list[str]                 # list of strings
scores: dict[str, int]          # str keys, int values
point: tuple[float, float]      # fixed 2-tuple
row: tuple[int, ...]            # variable-length tuple, all ints

# "Maybe absent" — two spellings, prefer the modern one
retries: Optional[int]          # old: Optional[int] == int | None
retries2: int | None            # modern, preferred (PEP 604)

# "One of several types"
token_id: Union[int, str]       # old spelling
token_id2: int | str            # modern

# The escape hatch — turns checking OFF for this value
payload: Any                    # anything goes; a checker will stop complaining

# A fixed set of allowed literal values (huge in GenAI code)
role: Literal["system", "user", "assistant"]

# A callable: takes an int, returns a str
formatter: Callable[[int], str]

JS → TS/Python: string[]list[str], Record<string, number>dict[str, int], [number, number]tuple[int, int], X | undefinedX | None, A | BA | B (identical!), anyAny, "a" | "b"Literal["a","b"], (n: number) => stringCallable[[int], str]. Python's Literal is TS's string-literal union — you'll see it constrain LLM roles and tool names constantly.

TypedDict describes the shape of a plain dict (a very JS-object-like thing) — useful when data stays a dict but you want editor help:

class Message(TypedDict):
    role: Literal["user", "assistant"]
    content: str

msg: Message = {"role": "user", "content": "Hi"}   # still a normal dict at runtime

Type aliases name a complex type; Annotated attaches metadata to a type (Pydantic and FastAPI mine this heavily):

type Embedding = list[float]              # 3.12 alias syntax; Embedding == list[float]
type ChatHistory = list[Message]

# Annotated[T, ...extra...]: the type is still `int`, but carries attached info
PositiveInt = Annotated[int, "must be > 0"]   # the string is metadata a tool can read

Reading & judging: When you see Any sprinkled through a file, read it as "typing was switched off here." One Any at a genuine boundary (e.g. arbitrary JSON) is fine. Any as the return type of core functions, or dict[str, Any] passed between every layer, is a smell — the author gave up the safety net, and your editor/checker can no longer catch mistakes downstream.

8.3 Static checkers: mypy and pyright

Because the runtime ignores hints, a separate tool reads them and flags contradictions — this is Python's stand-in for the TS compiler. The two common ones are mypy and pyright (pyright powers the Pylance VS Code extension, so you may already be running it).

def greet(name: str) -> str:
    return "Hi " + name

greet(42)   # mypy: Argument 1 to "greet" has incompatible type "int"; expected "str"
mypy app.py       # => error: Argument 1 ... incompatible type "int"; expected "str"
pyright app.py    # => equivalent error, different wording

They run in CI or your editor, never at runtime, and catch exactly the class of bug TS catches: wrong argument types, missing None checks, typos in attribute names. When you genuinely need to overrule the checker on one line, use a suppression comment:

result = some_untyped_lib.call()  # type: ignore[no-untyped-call]  # narrow, justified

Reading & judging: A file dotted with bare # type: ignore (no error code, no comment explaining why) is a red flag — each one is a place the author silenced the checker instead of fixing the type. Prefer the specific form # type: ignore[code] so only the named error is suppressed and new ones still surface.

8.4 Pydantic v2 — runtime validation, the workhorse

Static checkers only run on your source. They cannot check a JSON blob arriving from an LLM, an API, or a .env file at runtime — that data is invisible until the program is running. Pydantic fills that gap: you declare a model with type hints, and Pydantic enforces them on real data as the program runs, raising a clear error when reality doesn't match.

JS → TS/Python: Pydantic is zod. A zod schema validates unknown JSON at runtime and hands you a typed value; a Pydantic BaseModel does the same. Where z.object({ name: z.string() }).parse(data) throws ZodError, Model.model_validate(data) throws ValidationError. The mental model transfers almost one-to-one — and like zod, the schema is the type, so you write it once.

from pydantic import BaseModel, Field, ValidationError

class ChatRequest(BaseModel):
    model: str                                    # required, must be str
    messages: list[str]                           # required list of str
    temperature: float = 0.7                      # optional, has a default
    max_tokens: int | None = None                 # optional, may be None
    # Field() adds constraints + metadata (surfaces in JSON schema / docs)
    top_p: float = Field(default=1.0, ge=0.0, le=1.0, description="Nucleus sampling")

req = ChatRequest(model="claude-opus-4", messages=["hello"])
print(req.temperature)   # => 0.7
print(req.top_p)         # => 1.0

Required vs optional is decided by whether a field has a default. model and messages above are required; omit them and construction fails.

Automatic coercion — Pydantic will sensibly convert compatible types (a common surprise coming from strict TS):

r = ChatRequest(model="x", messages=["hi"], temperature="0.9")  # str "0.9"...
print(r.temperature, type(r.temperature))   # => 0.9 <class 'float'>  ← coerced to float

ValidationError is raised, with a precise report, when data can't be made to fit:

try:
    ChatRequest(model="x", messages="not-a-list", top_p=5)   # two problems
except ValidationError as e:
    print(e.error_count())   # => 2
    print(e.errors()[0]["loc"], e.errors()[0]["type"])
    # => ('messages',) list_type
    # top_p=5 fails the le=1.0 constraint; messages must be a list

Parsing and serializing

Four methods you'll see everywhere (note the model_ prefix — every v2 method uses it):

# FROM data → validated model
ChatRequest.model_validate({"model": "x", "messages": ["hi"]})       # from a dict
ChatRequest.model_validate_json('{"model":"x","messages":["hi"]}')   # from a JSON string

# FROM model → data
req.model_dump()        # => {'model': 'x', 'messages': [...], 'temperature': 0.7, ...}  (dict)
req.model_dump_json()   # => '{"model":"x",...}'  (JSON string)

Reading & judging — v1 vs v2 mismatch: This is the single most common version-confusion when reading Python AI code. Pydantic v1 used .dict(), .json(), parse_obj(), @validator, and an inner class Config. Pydantic v2 (what everything current uses) renamed these to .model_dump(), .model_dump_json(), .model_validate(), @field_validator, and model_config = ConfigDict(...). If you see .dict() or class Config: or @validator, you're reading v1-era code (or a bug against a v2 install) — flag it, it will break or emit deprecation warnings on modern Pydantic.

Nested models, validators, computed fields, config

from pydantic import BaseModel, Field, ConfigDict, field_validator, model_validator, computed_field

class Address(BaseModel):
    city: str
    country: str

class Customer(BaseModel):
    # model_config replaces v1's `class Config`
    model_config = ConfigDict(extra="forbid")     # reject unknown keys instead of ignoring

    name: str
    email: str
    address: Address                               # NESTED model — validated recursively
    orders: list[float] = Field(default_factory=list)   # mutable default → default_factory

    # FIELD validator: checks/normalizes ONE field. Must be a classmethod.
    @field_validator("email")
    @classmethod
    def email_has_at(cls, v: str) -> str:
        if "@" not in v:
            raise ValueError("email must contain @")
        return v.lower()                           # returned value replaces the field

    # MODEL validator (mode="after"): sees the whole built object, cross-field checks
    @model_validator(mode="after")
    def name_not_empty(self) -> "Customer":
        if not self.name.strip():
            raise ValueError("name cannot be blank")
        return self

    # COMPUTED field: derived, and INCLUDED in model_dump() output (unlike a plain @property)
    @computed_field
    @property
    def order_total(self) -> float:
        return sum(self.orders)

c = Customer.model_validate({
    "name": "Ada", "email": "ADA@X.COM",
    "address": {"city": "London", "country": "UK"}, "orders": [10.0, 5.5],
})
print(c.email)         # => ada@x.com          (normalized by the field_validator)
print(c.order_total)   # => 15.5               (computed)
print(c.model_dump())
# => {'name': 'Ada', 'email': 'ada@x.com', 'address': {'city': 'London', 'country': 'UK'},
#     'orders': [10.0, 5.5], 'order_total': 15.5}   ← computed field is serialized too

Key rules to remember when reading these: @field_validator is stacked above @classmethod and receives one field's value; @model_validator(mode="after") runs on the fully-built self and is where cross-field logic lives; @computed_field sits above @property and, unlike a normal property, shows up in model_dump(). Use default_factory=list (never default=[]) for mutable defaults — a bare [] would be shared across instances, the same footgun as a default [] argument.

Settings from environment variables

pydantic-settings (a separate install: pip install pydantic-settings) reads config from env vars and .env files into a validated model — the standard way GenAI apps load API keys:

from pydantic import Field
from pydantic_settings import BaseSettings, SettingsConfigDict

class Settings(BaseSettings):
    # SettingsConfigDict is the settings-flavoured ConfigDict
    model_config = SettingsConfigDict(env_file=".env", env_prefix="APP_")

    anthropic_api_key: str                          # required → APP_ANTHROPIC_API_KEY
    model: str = "claude-opus-4"                     # APP_MODEL, default provided
    max_retries: int = Field(default=3, ge=0)        # coerced from the env string, validated

settings = Settings()          # reads real env vars / .env at call time
# print(settings.model)        # => claude-opus-4 (or whatever APP_MODEL is set to)

A missing required key fails loudly at startup with a ValidationError — far better than an undefined API key surfacing three calls deep.

8.5 Why this is the GenAI reliability pattern

LLMs return text. Even in "JSON mode" they can hallucinate a field, drop a required one, or return a number as a string. The industry-standard defense: define a Pydantic model as your target schema, send its JSON schema to the model, then validate whatever comes back into the model — so malformed output is rejected at the boundary instead of poisoning your program.

import json
from pydantic import BaseModel, Field, ValidationError

class LineItem(BaseModel):
    description: str
    amount: float = Field(ge=0)          # amounts can't be negative

class ExtractedInvoice(BaseModel):
    invoice_number: str
    currency: str = Field(min_length=3, max_length=3)   # ISO code, exactly 3 chars
    line_items: list[LineItem]
    total: float = Field(ge=0)

# 1) Hand the schema to the LLM so it knows the exact shape to produce:
schema = ExtractedInvoice.model_json_schema()
# ...include json.dumps(schema) in your prompt / pass as a tool definition...

# 2) A GOOD response from the model — validates cleanly:
good = '{"invoice_number":"INV-9","currency":"GBP","line_items":[{"description":"API credits","amount":40.0}],"total":40.0}'
inv = ExtractedInvoice.model_validate_json(good)
print(inv.total, inv.line_items[0].description)   # => 40.0 API credits

# 3) A HALLUCINATED response — currency wrong length, negative amount:
bad = '{"invoice_number":"INV-9","currency":"POUNDS","line_items":[{"description":"x","amount":-5}],"total":40.0}'
try:
    ExtractedInvoice.model_validate_json(bad)
except ValidationError as e:
    print(e.error_count())                        # => 2
    print([err["loc"] for err in e.errors()])
    # => [('currency',), ('line_items', 0, 'amount')]   ← caught before it hit your DB

The same pattern guards agent actions. An agent's "next step" is untrusted LLM text; validating it into a model with a Literal tool name means a hallucinated tool is rejected, not executed:

from typing import Literal
from pydantic import BaseModel

class AgentAction(BaseModel):
    tool: Literal["search", "calculator", "send_email"]   # only these three allowed
    args: dict[str, str]

# If the LLM invents tool="delete_database", model_validate raises ValidationError —
# the illegal action never reaches your dispatcher.

Reading & judging — the #1 thing to flag: untrusted LLM (or API) output parsed straight into code without a validation layer — e.g. data = json.loads(llm_output); do_thing(data["tool"]). That's a crash-or-worse waiting to happen: a missing key raises KeyError mid-flow, and a hallucinated tool/amount/SQL fragment flows unchecked into execution. The correct shape is always Model.model_validate_json(llm_output) at the boundary, with a try/except ValidationError and a retry or fallback. When you review agent code, trace every LLM response to its first use — if there's no Pydantic (or equivalent) gate between the model and the action, that's the finding.

Also watch for missing validation at other boundaries (request bodies, webhook payloads, file contents parsed with a bare json.loads and trusted) — same risk, same fix.

8.6 dataclass vs Pydantic vs TypedDict — picking the right tool

Tool Runtime validation? Use it for
@dataclass No (hints only) Trusted internal data you construct yourself — config objects, intermediate results. Lightweight, stdlib, no coercion.
Pydantic BaseModel Yes Any data crossing a boundary — LLM output, API requests, env vars, user input. The default for GenAI/agent code.
TypedDict No (hints only) Data that must stay a plain dict but you want type hints on its keys — e.g. message dicts passed to an SDK.
from dataclasses import dataclass

@dataclass          # no validation — you promise the data is already correct
class RetryConfig:
    attempts: int
    backoff: float

RetryConfig(attempts="oops", backoff=1.0)   # ← constructs happily; hint is not enforced

JS → TS/Python: A dataclass/TypedDict is a TS interface/type — compile-time shape, zero runtime cost or guarantee. A Pydantic BaseModel is a zod schema — a runtime gate that actually inspects the data. The engineering rule mirrors the frontend one: use interfaces (dataclass/TypedDict) for data you already trust inside your own code; use zod/Pydantic the moment data comes from outside — and in GenAI work, the LLM is always outside.

Try it: Take the ExtractedInvoice model, feed model_validate_json a payload with "total": "forty" and one with the line_items key missing entirely. Print e.errors() for each and read the loc/type/msg fields — that error structure is exactly what you'd log, or feed back to the LLM in a "your last output was invalid, fix these fields" retry loop. That loop, powered by ValidationError, is the backbone of reliable structured extraction.

Sources: Pydantic Fields, Pydantic Models, Pydantic Configuration, Pydantic Settings Management