Part 5 of 8 · 4 min read
Type hints and validation
Telling Python what each value should be, catching mistakes before and after the program runs, and why Pydantic checks data from outside.
Python lets you pass anything anywhere. That is convenient for small scripts and dangerous for services, where a wrong value may travel a long way before it causes a confusing error.
Type hint
A note that says what type a value should have, such as def add(a: int, b: int) -> int. Python ignores hints when it runs the program. A checker such as mypy reads them and reports mismatches before you run anything.
Here is a small program with a mistake on the last line:
def add(a: int, b: int) -> int:
return a + b
print(add(2, 3))
print(add("2", 3))
Python itself finds the mistake only when it reaches that line:
$ python demo_types.py
5
Traceback (most recent call last):
...
TypeError: can only concatenate str (not "int") to str
mypy finds it without running the program:
$ mypy demo_types.py
demo_types.py:5: error: Argument 1 to "add" has incompatible type "str"; expected "int" [arg-type]
Found 1 error in 1 file (checked 1 source file)
Type hints catch a whole class of agent bugs: passing a list where text is expected, returning None from a function whose caller does not expect it, or misspelling a dictionary key.
Hints do not check data from outside
A hint cannot protect you from data that arrives while the program runs, such as JSON from a web service, a form that a user filled in, or the reply of a model. For that you need validation: checking the data itself when it arrives.
Pydantic model
A class whose fields are type hints. When you create an instance, Pydantic checks the data and, where it sensibly can, converts it. If something is wrong, it raises a ValidationError with a precise message. The class is a form, and the data is a filled-in copy of it.
Here is the model from the example project:
from enum import Enum
from pydantic import BaseModel, Field
class Role(str, Enum):
SYSTEM = "system"
USER = "user"
ASSISTANT = "assistant"
class Message(BaseModel):
role: Role
content: str = Field(min_length=1)
class Conversation(BaseModel):
id: str
messages: list[Message] = Field(default_factory=list)
def add(self, role: Role, content: str) -> None:
self.messages.append(Message(role=role, content=content))
Read it:
Roleis an enum: a short, fixed list of allowed values. A message's role can only be one of these three words.Messagehas two fields.rolemust be one of theRolevalues.contentmust be text with at least one character (Field(min_length=1)adds that rule).Conversationholds an id and a list of messages.addis a helper that builds aMessageand appends it.BaseModelis what Pydantic provides to make a class check itself.
Now build a bad message and read the error. It lists every problem at once:
>>> Message(role="boss", content="")
2 validation errors for Message
role
Input should be 'system', 'user' or 'assistant' [type=enum, input_value='boss', input_type=str]
content
String should have at least 1 character [type=string_too_short, input_value='', input_type=str]
This is the foundation of structured output. In Level 2 you will ask a model to answer in JSON and check the reply with a Pydantic model. If the reply is wrong, the error message tells you exactly what to send back to the model.
Mutable defaults: what is safe where
A list is mutable: it can be changed in place. A beginner mistake is to give a list as a default value, so that every object shares one list. What happens depends on where you write it:
- In a dataclass,
xs: list[int] = []is refused by Python itself with the messagemutable default <class 'list'> for field xs is not allowed: use default_factory. - In a Pydantic model,
xs: list[int] = []is safe, because Pydantic makes a fresh copy for each object. Many teams still writeField(default_factory=list)so that the intention is clear, and so that the habit carries over to dataclasses. - In an ordinary function argument,
def f(xs=[])really does share one list between calls. UseNoneand create the list inside the function.
The example test test_conversations_do_not_share_message_lists checks that two conversations start with separate lists.
Check yourself
- Python ignores type hints when running. So what are they for?
- Why can a type hint not protect you from a bad reply from a model?
- What does
Field(min_length=1)add tocontent?