~/TechPurAI
~/tutorials/python-type-hints-that-catch-bugs
intermediate·standalone·6 min read

Python type hints that actually catch bugs

Updated Aug 31, 2026Python

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:

python
def total_price(unit_price: float, quantity: int) -> float:
    return unit_price * quantity

Python 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

bash
pip install mypy
mypy tracker.py

On 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:

python
def find_user(users: dict[str, str], user_id: str) -> str:
    return users.get(user_id)

mypy rejects this immediately:

text
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:

python
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.

This is the 80%

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:

python
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:

python
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:

python
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:

python
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 dict
text
error: 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:

python
def first_item(items: list[int]) -> int:  # forces every caller to only ever pass list[int]
    return items[0]
python
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

Making it stick

Add a config so the settings live in the repo rather than in the command someone remembered to type:

toml
[tool.mypy]
python_version = "3.11"
warn_return_any = true
warn_unused_ignores = true

Then 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.

VK

Vijay Kumar

Founder of TechPurAI — writing hands-on tutorials and honest tool breakdowns.

LinkedIn ↗
next in language-features →Virtual environments and pip, explained