⚡ AMP
Python

Python type hints and mypy for safer code

A practical guide to python type hints and mypy for safer code.

Nitheesh DR 4 min read

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

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.