Skip to content
Phase 1 · Foundations
Topic 1011 of 12

Type hints

Drill: drills/d10_type_hints.py · Test: uv run pytest phases/01-foundations/part-a/tests/test_10_type_hints.py

You have been typing every signature since topic 01. This lesson fills in the parts that were used without explanation and adds the two constructs that matter most in this field: Literal and TypedDict.

AGENTS.md sets the bar: every signature fully typed including the return, pyright in strict mode, no blanket Any. The reason is in the phase doc — “AI apps are schema-heavy; typing catches contract breaks before runtime.” A wrong role string is a 400 from the provider, after the network round trip and after you paid for the tokens. pyright catches it in your editor for free.

Types are checked by pyright, never by Python

def count(text: str) -> int:
    return "not an int"  # runs fine; pyright flags it

Annotations are metadata. Python does not enforce them at runtime — Pydantic does that, at boundaries you choose, and Part B covers it. Everything below is a static-analysis contract.

The basics you have been using

def summarise(messages: list[dict[str, str]], limit: int = 10) -> str: ...

Built-in generics (list[str], dict[str, int], tuple[str, int]) work directly since 3.9. Do not import List or Dict from typing; that spelling is deprecated.

Tuples have two forms, and mixing them up is a common error:

tuple[str, int]  # exactly two items: a str then an int
tuple[str, ...]  # any number of strs

Optional and unions

def find(key: str) -> str | None: ...  # modern spelling
def find(key: str) -> Optional[str]: ...  # older; identical meaning

Use X | None. Optional[X] means the same thing and reads like “this argument is optional,” which is not what it means — it means “this value may be None.”

pyright forces you to handle the None before using the value:

result = find("a")
print(result.upper())  # error: "upper" is not a known attribute of "None"

if result is not None:
    print(result.upper())  # fine — pyright narrowed the type

That narrowing is the feature. Every AttributeError: 'NoneType' object has no attribute you have ever seen is a bug pyright would have caught.

Literal — a closed set of strings

from typing import Literal

Role = Literal["user", "assistant", "system"]


def add(role: Role, content: str) -> None: ...


add("user", "hi")  # fine
add("User", "hi")  # pyright: "User" is not assignable to Role

AGENTS.md requires Literal for closed sets of string values, and this is why. role: str accepts "usr", "User", and "banana". role: Role accepts three strings, and your editor autocompletes them.

Everywhere in this stack has a closed set: message roles, model IDs, finish reasons, stream event types, HTTP methods. Type them all this way.

Literal also drives exhaustiveness checking, via assert_never:

from typing import assert_never


def label(role: Role) -> str:
    if role == "user":
        return "You"
    if role == "assistant":
        return "Claude"
    if role == "system":
        return "System"
    assert_never(role)  # pyright: only valid if nothing is left unhandled

pyright narrows role a little further with each check. By the last line it should be Never — the type with no possible values — and assert_never asserts exactly that. Add a fourth role to Role later and this line becomes a type error pointing straight at the gap, in every function that stopped being exhaustive. At runtime the call is unreachable.

That is refactoring you can trust, and it is worth the one extra line.

TypedDict — a dict with a known shape

AGENTS.md: “TypedDict for untyped-JSON boundaries you do not own.”

from typing import TypedDict


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


def total(usage: Usage) -> int:
    return usage["input_tokens"] + usage["output_tokens"]  # keys are checked

It is a plain dict at runtime — zero cost, no class, no conversion. It is only a static description. usage["input_tokns"] is now a type error rather than a 2am KeyError.

Optional keys use NotRequired:

from typing import NotRequired


class Message(TypedDict):
    role: str
    content: str
    name: NotRequired[str]  # may be absent

TypedDict vs dataclass vs Pydantic

Three ways to describe a shape, and the choice is not arbitrary:

TypedDict@dataclassPydantic
Runtime objectplain dictan instancean instance
Validatesnonoyes
Costzeronear zerosome
Use forJSON you pass throughinternal valuesuntrusted input
  • Reshaping an API response you immediately re-serialise → TypedDict.
  • An internal value you construct yourself → dataclass (topic 07).
  • Anything crossing a trust boundary — API responses you depend on, user input, LLM output → Pydantic, in Part B.

Typing functions: Callable

from collections.abc import Callable

Callable[[str], int]  # takes a str, returns an int
Callable[[], None]  # takes nothing
Callable[..., str]  # any arguments, returns str — for decorators

Import from collections.abc, not typing. You used all three in topics 02 and 09.

Parameters vs returns: be generous in, specific out

def process(items: Iterable[str]) -> list[str]: ...

Accept the widest type you can handle — Iterable[str] takes lists, tuples, sets, and generators. Return the most specific type you actually produce — a caller who gets list[str] can index it; one who gets Iterable[str] cannot.

On Any

def parse(raw: str) -> Any: ...  # switches off type checking downstream

Any is not “unknown” — it is “stop checking.” It spreads: every value derived from an Any is also unchecked. AGENTS.md forbids silencing pyright with it.

Use object instead when you genuinely do not know. object is honest — it forces the caller to narrow with isinstance before doing anything, which is exactly the behaviour you want at a JSON boundary. That is why json.loads results are typed object in the topic 04 and 05 solutions.

Where this shows up later

  • Phase 2Literal for model IDs and roles; a Protocol for the client.
  • Phase 3 — Pydantic models generating JSON Schema for structured outputs.
  • Phase 5 — typed tool signatures converted into the schema a model consumes.

Do the drills

drills/d10_type_hints.py. Six exercises. The tests check runtime behaviour, but the real check is uv run pyright — a wrong annotation passes pytest and fails pyright, which is the whole point of having both.