CoursePython · Problem Decomposition, Data Structures, and Typed Records · part 12 of 79
Part 12 · Problem Decomposition, Data Structures, and Typed Records

Lesson 5: Type Hints as Design

9 min read·9 Sept 2026

Type hints are usually taught as documentation you add afterwards. They are more useful understood as design: writing the type first forces you to decide what a value can actually be, and that decision is the design.

Optional, Union, Literal, TypedDict, and aliases

| None for values that may be absent. Python's modern syntax uses the pipe operator.

python
def find_document(doc_id: str) -> Document | None:
    """Return the document, or None if no document has that id."""

You will also see Optional[Document] from the typing module, which means exactly the same thing. The pipe form is preferred in current code. The important part is not the syntax but what the annotation forces: by writing | None you have declared that callers must handle absence, and mypy will require them to.

python
doc = find_document("abc123")
print(doc.content)          # mypy error: item "None" has no attribute "content"

if doc is not None:
    print(doc.content)      # fine, mypy has narrowed the type

That narrowing is why the is None discipline from Lesson 3 pays off twice. It prevents the truthiness bug and it gives the type checker something to reason about.

Unions for values with more than one possible type.

python
def parse_servings(raw: str | int | None) -> int | None:
    """Accept whatever the scraper produced and return a count, or None."""

Use unions honestly rather than defensively. A function accepting str | int | float | None | list[str] is usually a design problem in disguise, and the fix is normalising the input earlier so the function only accepts one thing.

Literal for closed vocabularies. Lesson 1 noted that the rejection reason strings would drift. Literal fixes it.

python
from typing import Literal

RejectionReason = Literal["not_readable", "too_short", "duplicate", "no_content"]
DocumentStatus = Literal["pending", "valid", "rejected"]


@dataclass(frozen=True)
class Document:
    status: DocumentStatus = "pending"
    rejection_reason: RejectionReason | None = None

Now status="valid" is accepted and status="Valid" is a type error caught before running. The set of legal values is written down in one place, so a reader can see every possibility without searching the codebase, and adding a new one is a deliberate edit rather than a new string appearing somewhere.

Use Literal any time a string field has a fixed set of allowed values. Status fields, mode flags, roles in a message, and tool names are all good candidates.

TypedDict for dictionaries whose shape you cannot change. Sometimes you must work with a dict rather than a record, usually because it comes from or goes to an external system that speaks JSON.

python
from typing import TypedDict


class UsageDict(TypedDict):
    input_tokens: int
    output_tokens: int


def log_usage(usage: UsageDict) -> None:
    total = usage["input_tokens"] + usage["output_tokens"]

This gives you key checking and autocomplete on a plain dict, with no runtime cost, because it is purely a hint to the type checker. Note the limitation: TypedDict describes the shape you expect, and it validates nothing at runtime. If the API sends a string where an integer was promised, TypedDict will not notice. Prefer a dataclass or a Pydantic model for data you control, and reserve TypedDict for dictionaries whose dictionary-ness is fixed by something outside your program.

Aliases for readability. When a type appears repeatedly or is hard to read, name it.

python
type DocumentId = str
type ScoredDocument = tuple[Document, float]
type SearchResults = list[ScoredDocument]


def rank(documents: list[Document], query: str) -> SearchResults: ...

The type statement shown here is the modern form, available in Python 3.12 and later. In earlier versions you write DocumentId = str as a plain assignment, which still works.

DocumentId = str does not create a new type, and passing any string where a DocumentId is expected will not be flagged. Its value is in communication: a reader seeing DocumentId knows the intent, where str tells them nothing. For genuine type separation you would use NewType, which is worth knowing exists but rarely worth the friction.

Pydantic as the validating cousin of the dataclass

A dataclass checks nothing at runtime. This is a genuine gap:

python
@dataclass
class Document:
    id: str
    word_count: int


doc = Document(id=123, word_count="many")   # runs fine, no complaint

The annotations are hints. mypy would flag this line if it could see it, but if those values came from a JSON file or an API response, mypy never sees them, because it cannot know what a file contains. Static checking covers what is knowable at write time. Data arriving while the program runs is a different problem.

Pydantic is a validation library whose models look almost identical to dataclasses and enforce their types at runtime. [VOLATILE: Pydantic v2.13 was current at time of writing, and v2 remains the current major version. Verify before publishing.]

python
from pydantic import BaseModel, Field


class Document(BaseModel):
    id: str
    content: str
    word_count: int = Field(ge=0)
    status: DocumentStatus = "pending"


doc = Document(id=123, content="...", word_count="many")

That raises a ValidationError describing exactly what was wrong with which field. Note also that id=123 would be coerced to the string "123" by default, since Pydantic converts where conversion is unambiguous. That coercion is convenient and occasionally surprising, so it is worth knowing it is happening.

Pydantic also does what a dataclass cannot:

python
class Document(BaseModel):
    id: str
    content: str = Field(min_length=1)
    word_count: int = Field(ge=0)
    source_url: str | None = None
    status: DocumentStatus = "pending"


# Build from untrusted external data in one step
doc = Document.model_validate(json_payload)

# Serialise back out
payload = doc.model_dump()
json_text = doc.model_dump_json()

Choosing between them. The distinction is about where the data comes from.

Use a dataclass whenUse Pydantic when
Data originates inside your programData arrives from outside it
You want zero runtime overheadYou need values checked, not just annotated
The record is internal plumbingThe record crosses a boundary
Types are already guaranteedTypes are a hope until proven

In practice a typical service uses both. Pydantic models at the edges, where JSON, HTTP requests, database rows, and model outputs arrive. Dataclasses inside, where data has already been proven correct and validating it repeatedly is wasted work.

You have already used Pydantic once, in Module 1, for the settings object. That was the same idea applied to environment variables, which are external data arriving as untrusted strings.

Normalize at the boundary: parse at the edge, trust inside

This is the principle the whole module has been building towards.

Data becomes typed as early as possible, and stays typed. Validate once, at the point where data enters your program, and afterwards let every internal function assume it is correct.

The alternative, which is what most codebases do by accident, is defensive checking everywhere:

python
def summarize(doc) -> str:
    if doc is None:
        return ""
    content = doc.get("content") if isinstance(doc, dict) else getattr(doc, "content", None)
    if not content:
        return ""
    if not isinstance(content, str):
        content = str(content)
    return content[:200]

Every function repeats the same defensive dance, none of them is sure what it is receiving, and the checks are subtly different in each place, so behaviour depends on which path the data took. This is a codebase in which nobody can say what a document is.

With a boundary:

python
# At the edge: parse untrusted input once
def load_document(raw: dict) -> Document:
    """Validate external data into a trusted record. Raises on bad input."""
    return Document.model_validate(raw)


# Inside: trust it completely
def summarize(doc: Document) -> str:
    return doc.content[:200]

summarize has no checks because it needs none. Its parameter is annotated Document, and the only way to hold a Document is to have passed validation. The type is a guarantee rather than a hope.

[IMAGE PROMPT M2-5
Purpose: Show the parse-at-the-boundary architecture, with untrusted data outside and typed records inside a single validation edge.
Visual type: Boundary architecture diagram with inbound and outbound crossings.
Prompt: A clean educational architecture diagram. A large rounded rectangle occupies the centre, labelled inside at the top "Your application: everything here is typed and trusted", containing three small connected boxes labelled "clean", "deduplicate", and "summarize", linked left to right by arrows, with a small note beneath them reading "no defensive checks needed". The rectangle's border is drawn as a thick line labelled "validation boundary". Outside the rectangle on the left are three source icons labelled "JSON file", "HTTP response", and "database row", each with an arrow pointing at a gate on the boundary labelled "parse and validate". Just inside that gate, an arrow is labelled "Document record". Outside on the right is one destination icon labelled "API response", reached through a second gate on the boundary labelled "serialize". A small marker on the inbound gate reads "invalid input rejected here".
Required elements: A clearly drawn boundary rectangle, three external sources on the left, one destination on the right, labelled inbound and outbound gates, internal processing steps, the rejection marker on the inbound gate.
Style: Clean educational illustration, professional, uncluttered, high contrast, flat vector, generous whitespace.
Layout: Horizontal left to right, sources on the left, application interior in the centre, destination on the right.
Text labels: "Your application: everything here is typed and trusted", "validation boundary", "JSON file", "HTTP response", "database row", "parse and validate", "Document record", "clean", "deduplicate", "summarize", "no defensive checks needed", "serialize", "API response", "invalid input rejected here".
Aspect ratio: 16:9
Accessibility: Distinguish inside from outside using the boundary line and explicit text labels rather than colour alone.
Avoid: Vendor logos, decorative icons, screenshots, tiny text, watermarks, clutter.
Alt text: Architecture diagram showing untrusted JSON, HTTP, and database data passing through a single parse-and-validate gate into an application interior where all data is typed Document records requiring no defensive checks, with a separate serialize gate for outbound responses.
END IMAGE PROMPT]

Where the boundaries are. In a typical AI service: reading files or scraped content, HTTP request bodies, responses from a model provider, rows loaded from a database, messages from a queue, and configuration from the environment. Each one is a place where data you did not create enters code you did.

Model outputs deserve particular emphasis. A model's response is untrusted input in exactly the same sense as a form submission, because it is generated text that may not match the shape you asked for. A later module builds this out fully, but the principle is set here.

What "trust inside" means in practice. It does not mean ignoring errors. It means not re-checking things already established. Inside the boundary you still handle a network timeout or a missing file. You do not check whether doc.content is a string, because the type system already answered that.

Concept check. You are reading a JSONL file where each line is a scraped page. Some lines are malformed JSON. Some are valid JSON but missing the content field. Where do you validate, and what do you do with the bad lines?

Answer

Validate at the read function, which is the boundary, and nowhere else. One function turns a line of text into either a Document or a rejection, and everything downstream receives only Document objects.

Both failure types are handled there. Malformed JSON raises a decode error, and valid JSON missing a required field raises a Pydantic ValidationError. Catch both, and rather than discarding them, produce the same kind of rejection record from Lesson 4 carrying the line number, the source path, and the reason.

The important consequence is that no function after the boundary contains a check for a missing content field, because a Document that reached them cannot have one.