Python Type Hints and Mypy for Safer Code
You're three months into a Django project when a teammate refactors a function you rely on. They rename a parameter, change what it returns from a dict to a dataclass, and ship it. Nothing breaks until 2am in production, when your code calls .get() on an object that no longer supports it. There's no compiler to catch this. Python just runs the wrong code until it crashes.
Type hints, combined with a static checker like mypy, close that gap. They don't change how Python runs — they're a layer of documentation the interpreter ignores but tooling can verify.
The basics
def get_user_email(user_id: int) -> str | None:
user = db.find_user(user_id)
return user.email if user else None
This tells readers (and mypy) that user_id is an int, and the function returns either a string or None. Nothing here is enforced at runtime — Python will happily let you pass a string — but mypy will flag it before you ship.
Install it:
pip install mypy
mypy your_module.py
Typing collections and structures
from dataclasses import dataclass
@dataclass
class Order:
id: int
items: list[str]
total: float
discount_code: str | None = None
def apply_discount(order: Order, percent: float) -> Order:
order.total *= (1 - percent / 100)
return order
Since Python 3.9, you can use list[str] and dict[str, int] directly instead of importing List/Dict from typing. If you're on 3.9 or newer, skip the typing imports for these — it's less noise.
For anything with multiple valid shapes, reach for Union (or the | syntax on 3.10+) and Literal:
from typing import Literal
def set_status(order_id: int, status: Literal["pending", "shipped", "cancelled"]) -> None:
...
Now calling set_status(1, "shiped") (typo) is a mypy error, not a silent bug that ships.
Protocols instead of inheritance
You don't need an abstract base class to define an interface. Protocol gives you structural typing — if it walks like a duck:
from typing import Protocol
class Serializable(Protocol):
def to_dict(self) -> dict: ...
def save(obj: Serializable) -> None:
db.write(obj.to_dict())
Any class with a to_dict method satisfies this, no inheritance required. This is the same idea as TypeScript's structural typing, and it fits Python's duck-typing culture far better than forcing everything through ABCs.
Common mistakes
- Typing everything as
Anyto make mypy stop complaining. This defeats the purpose —Anyis invisible to the checker. Use it sparingly, and only at real boundaries (untyped third-party libraries, dynamic JSON). - Skipping return types on public functions. Parameter types catch some bugs; return types catch the ones that happen three calls downstream.
- Not running mypy in CI. A type hint nobody checks is just a comment that lies over time as the code changes.
- Overusing
# type: ignore. Each one is a hole in your safety net. Add a short comment explaining why when you use it, so the next person doesn't assume it's just laziness.
What I'd actually use
Start with mypy --strict on new modules only — retrofitting strict mode onto a large untyped codebase is miserable and you'll give up. Add a mypy.ini that's strict for new packages and lenient (or ignored) for legacy ones, then tighten it module by module. Pair mypy with pydantic if you're validating external data (API payloads, config files) — type hints alone don't validate at runtime, and pydantic gives you both the hints and the runtime checks in one model.
If your team already uses Ruff for linting, its type-checking overlap is limited — it won't replace mypy for real type inference, just some of the low-hanging errors.
Next steps
Add type hints to one module you touch regularly, run mypy against just that file, and fix what it finds. Once it's clean, add it to your CI pipeline so it can't regress. Do that for a handful of files before attempting a repo-wide rollout — chasing 100% coverage on day one is how these efforts stall out.