Python type hints that actually catch bugs
Type hints don't make Python faster and they don't stop a program from running with the wrong types. What they do is let a checker read your code and tell you about the crash before your users find it. Used well that's a genuine win; used indiscriminately it's clutter. This covers the difference.
The basics
A hint goes after the parameter name and before the return type:
def total_price(unit_price: float, quantity: int) -> float:
return unit_price * quantityPython ignores these at runtime — pass strings and it will happily concatenate them. The value comes from tooling: your editor now offers accurate completions, and a checker can prove the call sites are consistent.
Running mypy
pip install mypy
mypy tracker.pyOn code with no hints, mypy reports almost nothing — it has nothing to check against. Add hints to one function and it starts verifying every call to it. That gradual property is the point: you can annotate the parts that matter and leave the rest alone.
The hint that finds real bugs
Consider a lookup that sometimes fails:
def find_user(users: dict[str, str], user_id: str) -> str:
return users.get(user_id)mypy rejects this immediately:
error: Incompatible return value type (got "str | None", expected "str")The signature promises a str, but .get() returns None for a missing key. That's the bug — every caller doing find_user(...).upper() crashes with AttributeError: 'NoneType' object has no attribute 'upper' the first time an ID isn't found. The honest signature says so:
def find_user(users: dict[str, str], user_id: str) -> str | None:
return users.get(user_id)Now mypy flags the callers that use the result without checking it, which is exactly where the fix belongs.
Most of the value in type hints comes from | None on things that can be missing. Optional returns, optional parameters, database lookups, os.environ.get() — annotate those honestly and let the checker find every place that forgot to handle the empty case.
Collections
Annotate what's inside a container, not just the container:
def categories(expenses: list[dict[str, str | float]]) -> set[str]:
return {e["category"] for e in expenses}list alone tells a reader nothing they couldn't guess. list[dict[str, str | float]] says these are records with string keys — which is also the point at which the annotation gets ugly enough to signal that the data deserves a real type.
Give shapes a name
When a dictionary has a fixed set of keys, a dataclass beats an ever-growing annotation:
from dataclasses import dataclass
@dataclass
class Expense:
amount: float
category: str
note: str = ""
def total_for(expenses: list[Expense], category: str | None = None) -> float:
if category is None:
return sum(e.amount for e in expenses)
return sum(e.amount for e in expenses if e.category == category)The signature is now readable, and mypy catches e.catgeory as an error instead of returning KeyError at runtime the way a dictionary would. You also get __init__, __repr__, and __eq__ generated for free, which is usually reason enough on its own.
Ruling out None once
Checking for None narrows the type for the rest of the block:
def greet(name: str | None) -> str:
if name is None:
return "Hello, stranger"
return f"Hello, {name.title()}"After the early return, mypy knows name is a str — so .title() is allowed with no cast and no assertion. Structuring code as an early return for the empty case is both the clearest style and the one the checker understands best.
A dict with a fixed, known set of keys: TypedDict
The Expense dataclass above is the right tool when you control the data's construction. When the shape instead comes from something external — a JSON API response, for instance — that you're only reading, not constructing, TypedDict annotates a plain dict's expected keys without changing it into a different kind of object at runtime:
from typing import TypedDict
class UserRecord(TypedDict):
id: int
email: str
is_active: bool
def get_user(user_id: int) -> UserRecord:
return api_client.fetch(f"/users/{user_id}") # actually returns a plain dicterror: TypedDict "UserRecord" has no key "emial"mypy checks record["emial"] (a typo) against the declared keys and catches it — the same protection a dataclass gives you, but for data that's genuinely a dict at runtime (as JSON always is), not an object you're constructing yourself.
Generic functions with TypeVar
A function that works the same way regardless of the specific type it's handling — the first item in any list, say — loses that generality if annotated with one concrete type:
def first_item(items: list[int]) -> int: # forces every caller to only ever pass list[int]
return items[0]from typing import TypeVar
T = TypeVar("T")
def first_item(items: list[T]) -> T:
return items[0]TypeVar says "whatever type goes in is the same type that comes out" — call first_item(["a", "b"]) and mypy infers T = str, returning str; call it with a list of Expense objects and it infers T = Expense. This is what lets a single, real utility function stay usefully generic instead of needing a near-identical copy written for every type it might handle.
What not to annotate
- Obvious locals.
count: int = 0tells nobody anything. Annotate signatures — the boundaries between pieces of code — not every assignment. Anyeverywhere.def process(data: Any) -> Anytype-checks perfectly and verifies nothing. It's worse than no hint, because it looks like coverage.- Code you're about to delete. Hints have a maintenance cost; spend it on the code that other code depends on.
Making it stick
Add a config so the settings live in the repo rather than in the command someone remembered to type:
[tool.mypy]
python_version = "3.11"
warn_return_any = true
warn_unused_ignores = trueThen run mypy . in CI. Start there rather than at strict = true — on an existing codebase, strict mode produces hundreds of errors at once, and a wall of errors that nobody triages is the same as no checker at all.
FAQ
Do type hints have any runtime cost?
No — Python evaluates and discards them (or, with from __future__ import annotations, doesn't even evaluate them) rather than enforcing them at runtime. All the value comes from a checker like mypy reading them statically, not from anything happening while the program runs.
Should I use TypedDict or a dataclass for a new project I'm designing myself?
A dataclass, in most cases — it gives you a real constructor, equality, and a repr for free, and mypy catches the same typos. Reach for TypedDict specifically when the data is already a dict you don't control the construction of, like a parsed JSON payload.
What's the difference between Any and not annotating something at all?
Leaving a parameter unannotated means mypy doesn't check calls to it in --strict mode but may still infer something useful from context. Explicitly annotating it Any tells mypy to stop checking that specific value entirely, even in strict mode — which is why Any used defensively "to make an error go away" is worse than leaving the annotation off.
Is TypeVar still needed with newer Python versions?
Python 3.12 introduced a shorter generic function syntax (def first_item[T](items: list[T]) -> T:) that avoids the separate TypeVar declaration — check which Python version a given project targets before assuming the newer syntax is available, since TypeVar remains the correct approach for anything supporting earlier versions.