AI SWE Prep
0/9in Python Foundations
Python Foundations

Typing & Robust Code

Type hints, generics, protocols, and how static typing catches the bugs that surface at 3am in production.

~13 minLesson 10 of 60
Your progressNot started

Python is dynamically typed, but modern professional Python is written with type hints and checked statically. Hints don’t change how the program runs — the interpreter ignores them — but a type checker reads them to catch a whole category of bugs before you ever run the code. On a team, they double as always-accurate documentation.

From optional to essential

def greet(name: str) -> str:
    return f"Hello, {name}"

def total(prices: list[float]) -> float:
    return sum(prices)

user_id: int | None = None      # may be an int or None

A checker like mypy or pyright now knows that passing an int to greet, or forgetting to handle the None case for user_id, is an error — and tells you in your editor, instantly, instead of at 3am in production.

The types that carry their weight

How type hints pay off
  1. 1
    Annotateparams, returns, attributes
  2. 2
    Static checkmypy / pyright catch mismatches
  3. 3
    CI gateblock regressions before merge
  4. 4
    Editor + refactorautocomplete, safe renames
Types are checked before runtime — a whole class of bugs never ships.
  • Optional / | None — the single most valuable hint. It forces you to confront “this might be missing,” which is the source of countless NoneType has no attribute crashes.
  • Genericslist[str], dict[str, int], tuple[int, ...] describe what’s inside a container, so the checker tracks element types through your code.
  • TypedDict — precise types for dictionary-shaped data (like a JSON payload), naming each key and its type.
  • Literal — restrict a value to specific constants, e.g. Literal["gpt-4o", "gpt-4o-mini"].

Protocols: duck typing, checked

Python’s culture is duck typing — “if it has a .read() method, treat it as a file.” A Protocol lets you keep that flexibility and get static checking, by describing the shape an object must have rather than its class:

from typing import Protocol

class SupportsEmbed(Protocol):
    def embed(self, text: str) -> list[float]: ...

def index(store: SupportsEmbed, docs: list[str]) -> None:
    for d in docs:
        vec = store.embed(d)       # any object with embed() satisfies this
        ...

Any object with a matching embed method works — no inheritance required. This is how you write flexible, testable interfaces (swap a real embedding client for a fake one in tests) without giving up type safety.

Dataclasses and Pydantic: structured data

For plain records, @dataclass removes boilerplate:

from dataclasses import dataclass

@dataclass
class Chunk:
    text: str
    source: str
    score: float = 0.0

For data crossing a boundary — API requests, LLM outputs, config files — reach for Pydantic, which validates and coerces at runtime and raises a clear error when the data doesn’t match. It’s the standard way to make untrusted input safe, and (as you saw in the AI module) to enforce structure on model outputs.

The payoff

  • Fewer runtime crashes — an entire class of type bugs is caught before you run.
  • Fearless refactoring — change a signature and the checker lists every call site that must change.
  • Self-documenting APIs — the signature tells the reader (and your editor’s autocomplete) exactly what’s expected.

The habit to build: annotate every function signature, run a type checker in CI, and use Pydantic at the edges where data enters your system. Types are the cheapest reliability you can buy.

When to reach for this

Variations

Try it

These exercises are about making impossible states harder to express and missing cases harder to ignore.

ExerciseEasy
  • TypedDict
  • Literal

Add precise hints to a payload parser

Annotate a function that accepts API payloads shaped like {id, status, score}. status must be either "queued", "running", or "done", and the function returns only the completed item IDs.

Approach. Use TypedDict for known keys and Literal for the finite set of status strings. Accept a Sequence so callers can pass a list or tuple.

Show solution
from typing import Literal, Sequence, TypedDict


Status = Literal["queued", "running", "done"]


class JobPayload(TypedDict):
    id: str
    status: Status
    score: float


def completed_ids(jobs: Sequence[JobPayload]) -> list[str]:
    return [job["id"] for job in jobs if job["status"] == "done"]

A checker can now reject status="finished" at call sites you control and catch misspelled keys inside the function. Validate separately when the payload comes from the network.

ExerciseMedium
  • Protocol
  • ABC

Choose Protocol instead of ABC

You need load_text(source) to accept anything with a .read() -> str method: real files, io.StringIO, and test fakes. Should you require inheritance from an abstract base class?

Approach. This is structural: the function only needs a method shape. A Protocol captures that without forcing unrelated classes to inherit from your base class.

Show solution
from typing import Protocol


class TextReader(Protocol):
    def read(self) -> str: ...


def load_text(source: TextReader) -> str:
    return source.read().strip()


class FakeReader:
    def __init__(self, text: str):
        self.text = text

    def read(self) -> str:
        return self.text


assert load_text(FakeReader(" hello ")) == "hello"

Use an ABC when you own a hierarchy, need shared base behavior, or need runtime registration. Use a protocol when existing objects already have the behavior you need.

GotchaMedium
  • Optional
  • None

Handle Optional before attribute access

A lookup may return None, but this function crashes when the user is missing. Add type hints and fix the bug.

def display_name(user_id):
    user = find_user(user_id)
    return user.name.upper()

Approach. Put None in the return type of the lookup, then narrow before using .name. After the guard, the checker knows user is a real User.

Show solution
from dataclasses import dataclass


@dataclass
class User:
    id: int
    name: str


def find_user(user_id: int) -> User | None:
    return USERS_BY_ID.get(user_id)


def display_name(user_id: int) -> str:
    user = find_user(user_id)
    if user is None:
        return "UNKNOWN"
    return user.name.upper()

Optional is useful because it makes the missing case visible. If missing users are exceptional in your domain, raise a clear exception instead; do not pretend None is a User.

Your progressNot started