Welcome back. In the previous lesson, you transformed nested model-style dictionaries and lists into deliberate derived structures. That was useful, but it still left an important question implicit: what shape is this function expecting, and what shape does it promise to return?
This lesson makes those contracts visible with Python type annotations, then groups related configuration values into dataclasses. These are everyday tools for AI services: a configuration object can describe a model, generation limits, retry behavior, and request policies, while typed functions clarify the boundary between prompt-building, retrieval, and model-call code.
Type hints are contracts for developers and tools
Python is dynamically typed. A variable can refer to any kind of value at runtime, and annotations do not automatically coerce or validate values:
def repeat(text: str, times: int) -> str:
return text * times
The annotation says that repeat() expects a string and an integer, and returns a string. Python still runs the function body normally. A static type checker such as mypy examines the annotation before runtime and can flag a bad call; an IDE can use it for autocomplete, refactoring, and navigation.
This is especially valuable in an AI codebase, where ordinary dictionaries may represent messages, retrieved documents, tool results, or provider responses. A type signature lets a caller understand the intended shape without reading the implementation.
from collections.abc import Iterable
def render_transcript(messages: Iterable[str]) -> str:
return "\n".join(messages)
From the signature alone, a caller can see two things:
messagesmay be any iterable of strings: a list, tuple, generator, or set.- the function returns one concrete
str.
The distinction between Iterable[str] and list[str] is design information, not decorative syntax. If your function only loops over values, accepting Iterable[str] is flexible. If it depends on ordering, indexing, or slicing, a Sequence[str] or list[str] may communicate the actual requirement better.
The REAL Reason You Should Use Type Hints in Python
Watch “The REAL Reason You Should Use Type Hints in Python” by ArjanCodes for a practical design-oriented view: annotations make input and output expectations explicit, often revealing whether an API is unnecessarily narrow or vague.
Watch generic inputs to see why a function that only iterates should often accept an iterable rather than demand a list. Then watch return precision for the complementary idea that return types should state the capabilities callers can safely rely on. Treat this as a design heuristic, not an absolute rule: a deliberately abstract return type can still be appropriate at an architectural boundary.
A useful default rule is:
Accept the broadest type that genuinely supports the function’s behavior; return the most informative type that you actually produce.
For example, this function consumes messages one time and does not need to index them:
from collections.abc import Iterable
def render_transcript(messages: Iterable[str]) -> str:
return "\n".join(messages)
This one models a bounded conversation history. It needs an ordered collection and returns a new list:
from collections.abc import Sequence
def select_recent_messages(
messages: Sequence[str],
limit: int,
) -> list[str]:
if limit < 1:
raise ValueError("limit must be at least 1")
return list(messages[-limit:])
A generator would be valid for render_transcript(), but not naturally for select_recent_messages(): generators cannot be sliced and may only be consumed once.
Type hints cheat sheet - mypy 1.19.1 documentation
Read the mypy documentation’s compact reference for modern annotations. It is useful as a syntax reference while you build the examples in this lesson.
First, in “Useful built-in types,” review generic built ins: list, set, dict, fixed-length tuples, unions, and nullable values. Then read the complete “Functions” section. Begin with the first signature and continue through defaults, Callable, Iterator, and keyword-only parameters. Focus on reading an annotation as an interface contract, rather than memorizing every form.
Write typed functions around meaningful application data
Rather than annotating every local variable, prioritize public functions, methods, and values that cross a module boundary. Those are the places where a future caller most needs an accurate contract.
Here is a small chat-message type and two functions that use it:
from collections.abc import Iterable, Sequence
from dataclasses import dataclass
@dataclass(frozen=True)
class ChatMessage:
role: str
content: str
def render_transcript(messages: Iterable[ChatMessage]) -> str:
"""Format messages for inspection or a simple prompt."""
return "\n".join(
f"{message.role}: {message.content}"
for message in messages
)
def select_recent_messages(
messages: Sequence[ChatMessage],
limit: int,
) -> list[ChatMessage]:
"""Return a new list containing the final `limit` messages."""
if limit < 1:
raise ValueError("limit must be at least 1")
return list(messages[-limit:])
Notice what these annotations do and do not say:
| Signature choice | Meaning |
|---|---|
Iterable[ChatMessage] | The function only needs to traverse messages. It can accept a list, tuple, or generator. |
Sequence[ChatMessage] | The function expects ordered, indexable data suitable for a history-like collection. |
list[ChatMessage] return type | The function intentionally creates and returns a mutable concrete list. |
-> str | Callers may rely on receiving a string, not merely some iterable of characters. |
Type annotations can also capture optionality precisely. If a value may be absent, say so:
def normalize_user_query(query: str | None) -> str:
if query is None:
return ""
return query.strip()
The if query is None check is not merely defensive runtime code. It also narrows the type: after that branch, a type checker knows that query is a str.
Avoid using vague dict[str, object] or Any as a default answer to every structured-data problem. They have a place at truly dynamic boundaries, but they discard much of the help types can provide. When a collection has a stable meaning, give it a named representation. In this lesson, that representation is a dataclass; in the next lesson, you will use Pydantic models for validation of external structured data.
To check the examples in the reproducible project created earlier, add mypy as a development dependency:
uv add --dev mypy
Then run it against your example file:
uv run mypy main.py
You can make the project’s expectations explicit in pyproject.toml:
[tool.mypy]
python_version = "3.12"
strict = true
Use the Python version you pinned in your own project rather than copying 3.12 blindly.
Static checking complements tests; it does not replace them. For example, mypy can flag this call before execution:
render_transcript(["plain text"])
The function expects ChatMessage objects, not strings. At runtime, Python does not enforce the annotation automatically; the function would eventually fail when it tried to access .role on a string.
Dataclasses turn related fields into a named object
A plain dictionary is convenient for one-off data:
config = {
"model": "qwen2.5-7b-instruct",
"temperature": 0.2,
"max_output_tokens": 512,
}
But it has weak ergonomics as application configuration grows. Keys are stringly typed, typos are easy to make, defaults are scattered, and an editor cannot reliably discover its intended fields.
A dataclass is a regular Python class designed mainly to hold data. The @dataclass decorator generates useful methods based on annotated fields, including an initializer, a readable representation, and value equality by default.

from dataclasses import dataclass
@dataclass
class GenerationConfig:
model: str
temperature: float = 0.2
max_output_tokens: int = 512
Python effectively supplies an initializer with the same fields:
config = GenerationConfig(
model="qwen2.5-7b-instruct",
temperature=0.2,
max_output_tokens=512,
)
print(config)
# GenerationConfig(
# model='qwen2.5-7b-instruct',
# temperature=0.2,
# max_output_tokens=512
# )
It also generates field-by-field equality:
first = GenerationConfig(model="qwen2.5-7b-instruct")
second = GenerationConfig(model="qwen2.5-7b-instruct")
assert first == second
This is useful for configuration, test fixtures, request records, evaluation cases, and small internal domain objects. It is not a substitute for all classes: if the primary purpose of a type is complex behavior, inheritance, resource ownership, or a large behavioral API, an ordinary class may be clearer.
Read the relevant parts of the official Python documentation. It establishes exactly which methods dataclasses generate, how defaults work, and why mutable defaults need special handling.
Start with the opening overview and the generated-methods explanation. In “Module contents,” inspect the basic @dataclass signature and its init, repr, eq, frozen, and kw_only options. Next, read “Post-init processing,” beginning with post-init behavior. Finish with “Default factory functions” and “Mutable default values,” especially the shared-default problem. Focus on why field(default_factory=...) creates a fresh value for each instance.
Build an AI-service configuration object
Configuration tends to be long-lived and passed through many layers of a service. Positional construction becomes fragile as fields accumulate:
# Difficult to read and easy to misorder:
# GenerationConfig("model-name", 0.2, 512)
For configuration objects, keyword-only construction is usually a sensible default. It makes each setting self-documenting and allows fields to evolve more safely.
frozen=True prevents accidental reassignment after construction. That is useful when one request should use one stable configuration snapshot. It is an engineering guardrail, not a security boundary.
from dataclasses import dataclass, field
@dataclass(frozen=True, kw_only=True)
class RetryPolicy:
max_attempts: int = 3
timeout_seconds: float = 20.0
retryable_status_codes: frozenset[int] = frozenset(
{429, 500, 502, 503, 504}
)
def __post_init__(self) -> None:
if self.max_attempts < 1:
raise ValueError("max_attempts must be at least 1")
if self.timeout_seconds <= 0:
raise ValueError("timeout_seconds must be positive")
@dataclass(frozen=True, kw_only=True)
class GenerationConfig:
model: str
temperature: float = 0.2
max_output_tokens: int = 512
stop_sequences: tuple[str, ...] = ()
retry: RetryPolicy = field(default_factory=RetryPolicy)
def __post_init__(self) -> None:
if not self.model.strip():
raise ValueError("model must not be empty")
# This range is an example policy. Use the limits of your provider.
if not 0.0 <= self.temperature <= 2.0:
raise ValueError("temperature must be between 0.0 and 2.0")
if self.max_output_tokens < 1:
raise ValueError("max_output_tokens must be at least 1")
if any(not stop for stop in self.stop_sequences):
raise ValueError("stop_sequences must not contain an empty string")
Construct it with explicit names:
config = GenerationConfig(
model="qwen2.5-7b-instruct",
temperature=0.2,
max_output_tokens=512,
stop_sequences=("<|end|>",),
retry=RetryPolicy(
max_attempts=2,
timeout_seconds=15.0,
),
)
This design captures several deliberate choices:
modelis required because the service cannot make a model request without one.- defaults make standard behavior cheap to construct and visible in one place.
RetryPolicyis its own type because retry settings have a coherent meaning and validation rules.- tuples and frozensets express a configuration value that should not be mutated casually.
__post_init__()centralizes cross-field or domain-specific checks immediately after the generated initializer assigns fields.
The PyCon talk below gives a concise visual explanation of the last point: validation belongs alongside the type that owns the invariant, rather than being repeated throughout callers.
Talk - Bruce Eckel: Making Data Classes Work for You
Watch “Making Data Classes Work for You” by Bruce Eckel at PyCon US for a focused explanation of __post_init__() as a central place to protect a dataclass invariant.
Watch post-init validation. Focus on the lifecycle: the generated initializer assigns fields first, then __post_init__() checks whether the completed object is valid. The example uses a different domain, but the same pattern applies to model settings, timeout policies, and bounded generation parameters.
Immutability is shallow
frozen=True prevents this:
config.temperature = 0.8
# Raises dataclasses.FrozenInstanceError
But freezing does not recursively freeze objects stored inside a dataclass. If a frozen dataclass held a dict, the field could not be reassigned, yet the dictionary’s contents could still change.
from dataclasses import dataclass, field
@dataclass(frozen=True)
class RequestMetadata:
labels: dict[str, str] = field(default_factory=dict)
This is legal, and every RequestMetadata() gets a distinct dictionary. However, this can still mutate the internal dictionary:
metadata = RequestMetadata()
metadata.labels["experiment"] = "prompt-v3"
That may be exactly what you want for request-scoped metadata. For a stable configuration snapshot, prefer immutable representations where practical:
labels: tuple[tuple[str, str], ...] = ()
or keep the mutable data outside the frozen configuration object.
The default_factory detail is critical. Never use a mutable value such as {} or [] directly as a dataclass default. The factory is called for each instance:
from dataclasses import dataclass, field
@dataclass
class RequestOptions:
extra_headers: dict[str, str] = field(default_factory=dict)
Each RequestOptions instance now owns its own extra_headers dictionary.
Produce modified configuration safely
Frozen configurations do not mean configuration can never change. Instead of mutating a shared object, derive a new one with dataclasses.replace():
from dataclasses import replace
creative_config = replace(
config,
temperature=0.8,
max_output_tokens=768,
)
print(config.temperature) # 0.2
print(creative_config.temperature) # 0.8
replace() constructs a new instance, so the validation in __post_init__() still runs. This pattern is particularly useful for A/B experiments, task-specific overrides, and test cases: a baseline config remains intact while a variation is explicit.
Put the pieces together in main.py:
from collections.abc import Iterable, Sequence
from dataclasses import dataclass, field, replace
@dataclass(frozen=True)
class ChatMessage:
role: str
content: str
@dataclass(frozen=True, kw_only=True)
class RetryPolicy:
max_attempts: int = 3
timeout_seconds: float = 20.0
def __post_init__(self) -> None:
if self.max_attempts < 1:
raise ValueError("max_attempts must be at least 1")
if self.timeout_seconds <= 0:
raise ValueError("timeout_seconds must be positive")
@dataclass(frozen=True, kw_only=True)
class GenerationConfig:
model: str
temperature: float = 0.2
max_output_tokens: int = 512
stop_sequences: tuple[str, ...] = ()
retry: RetryPolicy = field(default_factory=RetryPolicy)
def __post_init__(self) -> None:
if not self.model.strip():
raise ValueError("model must not be empty")
if not 0.0 <= self.temperature <= 2.0:
raise ValueError("temperature must be between 0.0 and 2.0")
if self.max_output_tokens < 1:
raise ValueError("max_output_tokens must be at least 1")
def render_transcript(messages: Iterable[ChatMessage]) -> str:
return "\n".join(
f"{message.role}: {message.content}"
for message in messages
)
def select_recent_messages(
messages: Sequence[ChatMessage],
limit: int,
) -> list[ChatMessage]:
if limit < 1:
raise ValueError("limit must be at least 1")
return list(messages[-limit:])
def main() -> None:
config = GenerationConfig(
model="qwen2.5-7b-instruct",
temperature=0.2,
max_output_tokens=512,
retry=RetryPolicy(max_attempts=2, timeout_seconds=15.0),
)
messages = [
ChatMessage(role="system", content="Answer concisely."),
ChatMessage(role="user", content="What is retrieval augmented generation?"),
ChatMessage(role="assistant", content="It grounds generation in retrieved evidence."),
ChatMessage(role="user", content="Give one production concern."),
]
recent_messages = select_recent_messages(messages, limit=3)
experimental_config = replace(config, temperature=0.7)
print(render_transcript(recent_messages))
print(experimental_config)
if __name__ == "__main__":
main()
Run both the program and its static check:
uv run python main.py
uv run mypy main.py
This small pattern will scale cleanly when the configuration later includes provider selection, local model paths, context limits, retrieval settings, and tool policies.
Key takeaways
Type hints make Python function contracts visible to both humans and tooling. Use parameter types that reflect what the implementation truly needs, such as Iterable for one-pass traversal and Sequence for ordered history data. Return a type that accurately states what callers receive.
Dataclasses are an effective representation for structured internal configuration and small data-centric objects. With @dataclass, Python can generate constructors, readable representations, and field-based equality. Use:
kw_only=Truefor safer, self-documenting configuration construction;frozen=Truewhen a configuration snapshot should not be reassigned;field(default_factory=...)for per-instance defaults, especially mutable ones;__post_init__()for local invariants and derived initialization;replace()to derive a modified immutable configuration.
One crucial boundary remains: annotations and dataclasses do not fully validate untrusted JSON-like input at application boundaries. In the next lesson, you will use Pydantic models to parse and validate structured AI inputs and outputs before the rest of your application consumes them.
Can't find a good explanation? Sign up and we'll make it for you
Sign up