Recommended resources
The freely available resources this lesson will direct you to.
Hello! In the previous lesson, you transformed document pages into dictionaries while preserving their source IDs and original page numbers. Those dictionaries work, but as an application grows, it becomes harder to tell which fields a function expects and what it returns.
Today you’ll make those contracts clearer with typed functions, a dataclass for a passage, and a regular class that assembles passages into context for a model. The examples continue the handbook-page scenario and should take about 40–45 minutes, including the videos, reading, and coding.
Give functions explicit contracts
A function annotation describes the values a function expects and the value it intends to return. Watch the first part of Corey Schafer’s Python Tutorial: Type Hints for the syntax and, especially, the distinction between a type hint and runtime enforcement.
Python Tutorial: Type Hints - From Basic Annotations to Advanced Generics
Corey Schafer introduces parameter and return annotations, then shows how to express a value that may be absent.
Watch basic type hints. Focus on the function signature, the use of None for an absent value, and why an annotation alone does not reject an incorrect argument when Python runs.
In your project, create app_types.py. Start with this function; you’ll add to the same file throughout the lesson:
from dataclasses import dataclass, field
def clean_page(text: str) -> str | None:
cleaned = text.strip()
if not cleaned:
return None
return cleaned
The parameter annotation says text should be a string. The return annotation says the result is either a string or None: a blank page has no passage to keep. The | form requires Python 3.10 or newer.
A caller must account for both possibilities:
page_text = clean_page(" Annual leave is 20 days. ")
if page_text is not None:
print(page_text.upper())
Checking is not None gives you a string to work with inside the if block. It also avoids treating a missing result as though it were text. For collections, annotations can describe their contents: list[str] means a list of strings, while dict[str, int] means string keys with integer values.
Type hints do not validate incoming data. Passing a number to clean_page is still possible at runtime; the function would fail when it reached .strip(). A static type checker can flag a mismatch in code it can analyze, but checking untrusted request or model data is a separate responsibility.
Decide what belongs in an object
A function is often enough for a transformation such as clean_page. A class becomes useful when data and behavior belong together across repeated calls. Each object created from a class is an instance, with its own attributes; a function defined on the class is a method.
MIT OpenCourseWare’s Lecture 17: Python Classes introduces the syntax. The lecture uses coordinates rather than documents, so concentrate on how __init__, self, and method calls work; we’ll apply them to AI context assembly shortly.
MIT OpenCourseWare demonstrates how a class initializes instance attributes and how a method uses those attributes.
In the class-definition segment, watch initializing instances, from the Coordinate definition through the explanation of self.x and self.y. Then skip to calling methods, from the distance method to the explanation that the object before the dot supplies self. You can skip the lecture’s longer memory diagrams.
For our application, a context builder will hold a configured maximum number of passages. Its build() method will use that setting whenever called. In a typed __init__ method, the return annotation is None, and you do not supply self when creating an instance: Python supplies it.
A passage itself is different. It primarily holds a source ID, page number, and text. Writing and maintaining a custom __init__ for those fields would add little value, which makes it a good candidate for a dataclass.
Use a dataclass for passage data
Read the selected portions of Real Python’s Data Classes in Python. Compare the generated conveniences with a regular class, and pay close attention to the treatment of a list-valued default.
Data Classes in Python (Guide) – Real Python
Real Python explains what @dataclass generates, how its annotated fields and defaults work, and how to give each instance its own mutable list.
In “Python’s Dataclass in a Nutshell,” read the comparison, from the DataClassCard example through the contrast with RegularCard. In “Basic Data Classes,” read fields and defaults, including the “Default Values,” “Type Hints,” and start of “Adding Methods” subsections. In “Advanced Default Values,” read mutable defaults, from the Deck example through field(default_factory=...). Focus on why a fresh list is needed for each instance.
Add the following definitions below clean_page in app_types.py:
@dataclass
class Passage:
source_id: str
page: int
text: str
tags: list[str] = field(default_factory=list)
def make_passage(
source_id: str, page: int, raw_text: str
) -> Passage | None:
text = clean_page(raw_text)
if text is None:
return None
return Passage(source_id=source_id, page=page, text=text)
@dataclass generates an initializer, so you can write Passage(source_id="handbook", page=3, text="Carry over 5 days.") without defining __init__ yourself. It also provides a readable representation and field-based equality by default. Unlike a dictionary, a passage exposes named attributes such as passage.source_id and passage.page.
The tags field illustrates a consequential detail: field(default_factory=list) creates a new empty list for each passage. A shared list would let tagging one passage unexpectedly affect another. You can omit tags when constructing a passage because it has a default; the required fields come first.
The return type of make_passage reflects its actual behavior: blank text produces None, while retained text produces a Passage. Notice that the function checks for None before putting text into the dataclass. Dataclass annotations describe the intended fields, but they do not themselves verify their runtime values.
Put behavior and configuration in a regular class
Now add a class below make_passage. Its instance stores a passage limit, and its methods use the typed passage objects you just defined:
class ContextBuilder:
def __init__(self, max_passages: int) -> None:
self.max_passages = max_passages
def render(self, passage: Passage) -> str:
return f"[{passage.source_id} p.{passage.page}] {passage.text}"
def build(self, passages: list[Passage]) -> str:
selected = passages[: self.max_passages]
return "\n\n".join(self.render(passage) for passage in selected)
self.max_passages is an instance attribute: one builder can use a limit of two while another uses a different limit. In builder.build(passages), Python supplies builder as self; you supply only the passages argument. The build signature tells a caller that it expects a list of Passage objects and returns one string.
This is a regular class because its main purpose is configured behavior, not simply holding a record. Its limit is a passage-count limit, not a token-budget calculation. The caller must also choose a sensible value, such as a positive integer; an int annotation alone does not establish that rule.
Finish the file with a small run that preserves the original page numbers:
pages = [
" Annual leave is 20 days. ",
" ",
" Carry over 5 days. ",
]
passages: list[Passage] = []
for page_number, raw_text in enumerate(pages, start=1):
passage = make_passage("handbook", page_number, raw_text)
if passage is not None:
passages.append(passage)
passages[0].tags.append("leave")
print(passages[1].tags) # []: its list belongs to this instance
builder = ContextBuilder(max_passages=2)
print(builder.build(passages))
Run uv run python app_types.py. The final output should be:
[handbook p.1] Annual leave is 20 days.
[handbook p.3] Carry over 5 days.
The blank second page was dropped, but the retained passage still says p.3. The dataclass keeps that location attached to the text; the builder uses it when rendering context.
To check the annotated code without executing it, add a development dependency and run a static checker:
uv add --dev mypy
uv run mypy app_types.py
A clean check means mypy found no type mismatch in the code it analyzed—not that every future input is valid. In particular, an external JSON object does not become a trustworthy Passage just because you intend to use it as one.
Takeaway
Use typed functions to make inputs and possible returns explicit, a dataclass for passage records with named fields, and a regular class when behavior depends on per-instance configuration. Keep None cases visible, give mutable dataclass fields a default_factory, and remember that annotations support static checking rather than runtime validation.
Next, you’ll validate nested request and response data with Pydantic—the boundary this lesson deliberately leaves untrusted.