In Java, this doesn't compile:
int maxTokens = "lots"; // error: incompatible types
Here is the "same" thing in Python, with a type hint:
max_tokens: int = "lots"
print(max_tokens, type(max_tokens).__name__)
lots str
No error. Python wrote down that you wanted an int, then stored a string anyway. Now imagine that value came from an LLM's JSON output and got passed to a billing function. In Java the compiler would have stopped you; in Python, nothing does unless you add something that does.
This article explains what a type hint actually is, builds a tiny validator from hints by hand, and then shows how pydantic does the same thing properly. That matters because every structured LLM output, every tool-call argument and every API request body in a Python AI service goes through exactly this machinery.
WHY THIS MATTERS
LLM output is untrusted text. When you ask for {"refund_amount": 120} you'll sometimes get "120", "₹120", null or a missing key. Your type hints don't protect you from any of that. A validator built from those hints does, and it can also turn the error into a message you send back to the model to fix itself.
Part I · What a hint is
Chapter 1: Hints are notes, not guards
The problem
Two terms first:
- Type hint (annotation): the
: intafter a name, or-> strafter a function's parameters. - Type checker: a separate program (mypy, pyright, your IDE) that reads your code without running it and complains about mismatches. It's the closest thing Python has to
javac, but it's optional and never runs in production.
MENTAL MODEL
A label on a shelf, not a lock on the door. A type hint is a label stuck on the shelf saying "ints go here". At runtime nothing reads the label before putting things on the shelf. Two kinds of readers can read the label: a type checker (before you run) and your own code (while running), which can look the label up and enforce it.
Details, built on the model
The label is stored, and you can read it. For a function it lives in __annotations__:
def refund(order_id: str, amount: int) -> bool:
return amount > 0
print(refund.__annotations__)
print(refund("A1", 5))
{'order_id': <class 'str'>, 'amount': <class 'int'>, 'return': <class 'bool'>}
True
Calling refund with wrong types still runs; Python only does what the body does. Here the body compares a string with 0 and then fails, deep inside, with a confusing message:
try:
refund("A1", "120")
except TypeError as e:
print("TypeError:", e)
TypeError: '>' not supported between instances of 'str' and 'int'
The error talks about > between str and int, not about "amount should be an int". That's the cost of unenforced labels: errors appear late and far from the cause.
For the reliable way to read hints, use typing.get_type_hints, which also resolves hints written as strings:
from typing import get_type_hints
print(get_type_hints(refund))
{'order_id': <class 'str'>, 'amount': <class 'int'>, 'return': <class 'bool'>}
MODEL RECAP
Recap: a hint is a shelf label that nothing enforces unless some code reads it.
Chapter 2: Build a validator from the labels
The problem
If hints are readable at runtime, we can write code that reads them and checks incoming data. That's all a validation library is. Let's build one in 25 lines so pydantic stops being magic.
MENTAL MODEL
A customs officer with the manifest. The class's annotations are a manifest: "field amount must be int". Data arriving (a dict from JSON) goes through customs. The officer walks the manifest line by line: is the field there? Is it the right type? If not, can it be safely converted ("120" → 120)? Every problem is written down; the parcel is only released if the list is empty.
Details, built on the model
from typing import get_type_hints
class ValidationError(Exception):
pass
def validate(cls, data: dict):
errors, clean = [], {}
for field, typ in get_type_hints(cls).items():
if field not in data:
errors.append(f"{field}: missing")
continue
value = data[field]
if not isinstance(value, typ):
try:
value = typ(value) # try to convert
except (ValueError, TypeError):
errors.append(f"{field}: expected "
f"{typ.__name__}, got {value!r}")
continue
clean[field] = value
if errors:
raise ValidationError("; ".join(errors))
obj = cls.__new__(cls)
obj.__dict__.update(clean)
return obj
class Refund:
order_id: str
amount: int
Each branch of the code is one step of the officer's walk. Now feed it what an LLM might produce:
ok = validate(Refund, {"order_id": "A1", "amount": "120"})
print(ok.amount, type(ok.amount).__name__)
for bad in [{"order_id": "A1"},
{"order_id": "A1", "amount": "₹120"}]:
try:
validate(Refund, bad)
except ValidationError as e:
print("rejected:", e)
120 int rejected: amount: missing rejected: amount: expected int, got '₹120'
Edge cases fall out of the model. What if the manifest says list[str]? isinstance(x, list[str]) doesn't work, because a generic type (a type with parameters in brackets) is a label that needs unpacking: the officer must check the outer list and then every item. Our toy officer can't read that manifest line:
try:
isinstance(["a"], list[str])
except TypeError as e:
print("TypeError:", e)
TypeError: isinstance() argument 2 cannot be a parameterized generic
Handling generics, Optional, nested classes, dates and good error messages is the work pydantic has already done.
MODEL RECAP
Recap: validation is a customs officer walking the annotation manifest, converting or rejecting each field.
Part II · Pydantic
Chapter 3: Pydantic is that officer, done properly
The problem
Pydantic is the library FastAPI, the OpenAI and Anthropic SDKs and most agent frameworks use to validate data. You subclass BaseModel, write annotations, and get a validator.
MENTAL MODEL
The manifest is compiled once, then checked fast. When Python creates your BaseModel subclass, pydantic reads the annotations once and compiles them into a validator (in Rust, in the pydantic-core package). Every time data arrives, that compiled officer runs. Same customs walk as ours, just prepared ahead and much more thorough.
Details, built on the model
from pydantic import BaseModel, Field, ValidationError
from typing import Literal
class Refund(BaseModel):
order_id: str
amount: int = Field(gt=0, le=50_000)
reason: Literal["damaged", "late", "other"]
tags: list[str] = []
r = Refund(order_id="A1", amount="120", reason="late")
print(r)
print(r.model_dump_json())
order_id='A1' amount=120 reason='late' tags=[]
{"order_id":"A1","amount":120,"reason":"late","tags":[]}
"120" became 120 (pydantic's default "lax" mode converts obvious cases). Field(gt=0, le=50_000) adds rules beyond the type, and Literal[...] only allows exact values. Here's what a broken LLM output gets back:
bad = '{"order_id": 7, "amount": -5, "reason": "angry"}'
try:
Refund.model_validate_json(bad)
except ValidationError as e:
for err in e.errors():
print(err["loc"], "->", err["msg"])
('order_id',) -> Input should be a valid string
('amount',) -> Input should be greater than 0
('reason',) -> Input should be 'damaged', 'late' or 'other'
Every problem is listed, with where it is (loc) and why. Note order_id: 7 was rejected: lax mode won't turn an int into a string. If you want no conversion at all, switch on strict mode:
from pydantic import ConfigDict
class StrictRefund(Refund):
model_config = ConfigDict(strict=True)
try:
StrictRefund(order_id="A1", amount="120", reason="late")
except ValidationError as e:
print(e.errors()[0]["msg"])
Input should be a valid integer
WHY THIS MATTERS
Choose lax for LLM output (models often quote numbers) and strict for internal data where a string-number means a bug upstream. And remember the officer only checks shape: amount=120 passes even if the real refund should be 12. Value correctness needs business checks.
MODEL RECAP
Recap: pydantic compiles the annotation manifest once and runs that officer on every input.
Chapter 4: The same manifest, sent to the model
The problem
To get structured output from an LLM, you have to tell it the shape you want. The standard format for describing shapes is JSON Schema, a JSON document saying "an object with these properties of these types, these required". It's what the tools parameter and structured-output features take.
MENTAL MODEL
One manifest, two directions. The same pydantic class is exported as JSON Schema and sent out to the model as instructions ("fill in this form"), and then used in as the customs officer on what comes back. One source of truth; the form and the checker can't drift apart.
Details, built on the model
import json
schema = Refund.model_json_schema()
print(json.dumps(schema, indent=1)[:600])
{
"properties": {
"order_id": {
"title": "Order Id",
"type": "string"
},
"amount": {
"exclusiveMinimum": 0,
"maximum": 50000,
"title": "Amount",
"type": "integer"
},
"reason": {
"enum": [
"damaged",
"late",
"other"
],
"title": "Reason",
"type": "string"
},
"tags": {
"default": [],
"items": {
"type": "string"
},
"title": "Tags",
"type": "array"
}
},
"required": [
"order_id",
"amount",
"reason"
],
"title": "Refund",
"type": "object"
}
The loop in the diagram is the important part: when validation fails, send e.errors() back to the model as a message and ask it to fix its answer. Here's that loop with a fake model that is wrong once, then right:
replies = iter([
'{"order_id": "A1", "amount": "lots", "reason": "late"}',
'{"order_id": "A1", "amount": 120, "reason": "late"}',
])
def fake_llm(messages):
return next(replies)
msgs = [{"role": "user", "content": "refund A1, it was late"}]
for attempt in range(3):
text = fake_llm(msgs)
try:
result = Refund.model_validate_json(text)
print("attempt", attempt, "ok:", result)
break
except ValidationError as e:
problem = e.errors()[0]
print("attempt", attempt, "fix:", problem["msg"])
msgs.append({"role": "user",
"content": f"Invalid: {problem}. Retry."})
attempt 0 fix: Input should be a valid integer, unable to parse string as an integer attempt 1 ok: order_id='A1' amount=120 reason='late' tags=[]
Libraries like Instructor wrap exactly this loop. The retry cap matters: without it, a model that keeps failing turns into an infinite bill.
MODEL RECAP
Recap: one class becomes both the form sent to the model and the officer checking its answer.
All the mental models, in one place
MODEL RECAP
- A label on a shelf, not a lock. Hints are stored in
__annotations__; Python never enforces them. Checkers read them before running; your code can read them while running. - A customs officer with the manifest. Validation walks each annotated field: present? right type? convertible? Collect every problem, release only clean data.
- Compiled once, checked fast. Pydantic turns annotations into a validator at class creation; lax converts obvious cases, strict converts nothing,
Fieldadds value rules. - One manifest, two directions. The class exports JSON Schema for the model and validates its reply; errors go back to the model in a capped retry loop.