◆ 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"becomesn: str = "x";function f(a: number): stringbecomesdef f(a: int) -> str:. The-> strafter the parens is the return type — think of it as the: stringthat 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 | undefined↔X | None,A | B↔A | B(identical!),any↔Any,"a" | "b"↔Literal["a","b"],(n: number) => string↔Callable[[int], str]. Python'sLiteralis 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
Anysprinkled through a file, read it as "typing was switched off here." OneAnyat a genuine boundary (e.g. arbitrary JSON) is fine.Anyas the return type of core functions, ordict[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. Azodschema validates unknown JSON at runtime and hands you a typed value; a PydanticBaseModeldoes the same. Wherez.object({ name: z.string() }).parse(data)throwsZodError,Model.model_validate(data)throwsValidationError. The mental model transfers almost one-to-one — and likezod, 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 innerclass Config. Pydantic v2 (what everything current uses) renamed these to.model_dump(),.model_dump_json(),.model_validate(),@field_validator, andmodel_config = ConfigDict(...). If you see.dict()orclass 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 raisesKeyErrormid-flow, and a hallucinatedtool/amount/SQL fragment flows unchecked into execution. The correct shape is alwaysModel.model_validate_json(llm_output)at the boundary, with atry/except ValidationErrorand 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/TypedDictis a TSinterface/type— compile-time shape, zero runtime cost or guarantee. A PydanticBaseModelis azodschema — 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
ExtractedInvoicemodel, feedmodel_validate_jsona payload with"total": "forty"and one with theline_itemskey missing entirely. Printe.errors()for each and read theloc/type/msgfields — 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 byValidationError, is the backbone of reliable structured extraction.
Sources: Pydantic Fields, Pydantic Models, Pydantic Configuration, Pydantic Settings Management