Modern Python Type Hints: A Practical Guide to Static Typing with mypy

Learn how Python's type hints actually work — from basic annotations to Protocols, TypedDict, and generics — and how to catch bugs before runtime with mypy. A hands-on, example-driven guide for intermediate developers.

Modern Python Type Hints: A Practical Guide to Static Typing with mypy

Python is dynamically typed, and that flexibility is a big part of why it's so pleasant to write. But as a codebase grows, the absence of type information starts to hurt: functions accept things they shouldn't, refactors break callers silently, and IDE autocomplete can only guess. Type hints, introduced in PEP 484 and steadily expanded ever since, let you annotate your code with the types you intend — without changing how Python runs. Paired with a static checker like mypy, those annotations turn a whole class of runtime bugs into errors you see before you ship.

This guide walks through type hints from the ground up: the syntax, the most useful tools in the typing toolbox, the modern syntax that landed in recent Python versions, and the pitfalls that trip people up. Every example is runnable.

The basics: annotating functions and variables

A type hint is just a colon-and-type after a parameter, and an arrow before the return type. They're optional, ignored at runtime by default, and purely advisory to tools and readers.

def greet(name: str, excited: bool = False) -> str:
    msg = f"Hello, {name}"
    return msg + "!" if excited else msg

print(greet("Ada", excited=True))   # Hello, Ada!

You can annotate variables too, though Python rarely needs you to — the checker infers most local types. Annotations shine on function signatures, class attributes, and anywhere inference would otherwise be ambiguous.

count: int = 0
ratios: list[float] = []

Container types

Since Python 3.9 you can subscript the built-in collection types directly — list[int], dict[str, int], tuple[int, ...] — instead of importing capitalized aliases from typing. This is the modern, preferred style.

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

def index_users(users: dict[int, str]) -> list[str]:
    return [name for _, name in sorted(users.items())]

print(total([1.5, 2.0, 3.25]))        # 6.75
print(index_users({2: "Bob", 1: "Ann"}))  # ['Ann', 'Bob']

Optional values and unions

A function that might return None should say so. Since Python 3.10 the cleanest way to express "either of these types" is the | operator. str | None reads better than the older Optional[str], though both mean the same thing.

def find_user(uid: int, users: dict[int, str]) -> str | None:
    return users.get(uid)

print(find_user(1, {1: "Ann"}))  # Ann
print(find_user(9, {1: "Ann"}))  # None

A crucial habit: once a value can be None, the checker forces you to handle that case before using it as a non-None value. That nudge alone eliminates a steady stream of AttributeError: 'NoneType' crashes.

Catching bugs with mypy

Annotations do nothing on their own — you need a checker to enforce them. mypy is the de facto standard. Install it with pip install mypy and run it against your files.

def double(n: int) -> int:
    return n * 2

result: int = double("not a number")

Running mypy on that file reports the mistake without ever executing the code:

$ mypy bad.py
bad.py:4: error: Argument 1 to "double" has incompatible type "str"; expected "int"  [arg-type]
Found 1 error in 1 file (checked 1 source file)

Run mypy in CI and in your editor (most language servers use it or a compatible engine) so problems surface as you type. Start lenient and tighten over time — mypy --strict turns on the full set of checks once you're ready.

Structural typing with Protocol

Sometimes you don't care what class an object is, only that it has the right methods — classic "duck typing." Protocol lets you express that statically. Any object with a matching shape satisfies the protocol; no inheritance required.

from typing import Protocol

class SupportsLen(Protocol):
    def __len__(self) -> int: ...

def first_half_len(obj: SupportsLen) -> int:
    return len(obj) // 2

print(first_half_len("abcd"))            # 2
print(first_half_len([1, 2, 3, 4, 5, 6]))  # 3

Both str and list implement __len__, so both are accepted — even though neither was written to know about SupportsLen. This is how you type "anything iterable" or "anything file-like" cleanly.

Precise dictionaries with TypedDict

JSON payloads and config objects are often plain dicts with a known set of keys. TypedDict describes that structure so the checker can verify keys and value types.

from typing import TypedDict

class Movie(TypedDict):
    title: str
    year: int

def describe(m: Movie) -> str:
    return f"{m['title']} ({m['year']})"

print(describe({"title": "Arrival", "year": 2016}))  # Arrival (2016)

If you pass a dict missing year, or with year as a string, mypy flags it. For richer validation at runtime as well, reach for dataclasses or a library like Pydantic — but for lightweight static checks on dict shapes, TypedDict is perfect.

Generics: writing code that works for any type

A function that returns the first element of a list should return whatever type the list holds. TypeVar ties the input and output types together so the relationship is preserved.

from typing import TypeVar
from collections.abc import Iterable

T = TypeVar("T")

def first(items: Iterable[T]) -> T | None:
    for item in items:
        return item
    return None

print(first([10, 20, 30]))  # 10  (checker knows this is an int)
print(first(["a", "b"]))    # a   (checker knows this is a str)

Python 3.12 introduced cleaner PEP 695 syntax that removes the explicit TypeVar declaration. If you're on 3.12 or newer, prefer it:

# Python 3.12+
def first[T](items: list[T]) -> T | None:
    for item in items:
        return item
    return None

class Stack[T]:
    def __init__(self) -> None:
        self._items: list[T] = []
    def push(self, item: T) -> None:
        self._items.append(item)

type Vector = list[float]   # a named type alias

Typing callables

Functions are values, so you'll want to type them when passing callbacks. Callable[[ArgTypes], ReturnType] does the job.

from collections.abc import Callable

def apply(fn: Callable[[int], int], value: int) -> int:
    return fn(value)

print(apply(lambda x: x * x, 5))  # 25

Common pitfalls

Mutable default arguments still bite. Type hints don't fix the classic shared-default bug; annotate the parameter as list[int] | None and create the list inside the function.

def append_to(value: int, target: list[int] | None = None) -> list[int]:
    if target is None:
        target = []
    target.append(value)
    return target

Hints are not enforced at runtime. By default Python does not check types when your program runs — greet(42) won't raise just because the hint said str. The annotations are stored in __annotations__ and used by tools; runtime enforcement requires explicit libraries.

Forward references. If a class refers to itself or to something defined later, wrap the name in quotes ("Node") or add from __future__ import annotations at the top of the file, which makes all annotations lazy strings and sidesteps the problem entirely.

Don't over-annotate locals. Let the checker infer obvious local variables; reserve explicit annotations for signatures and anything genuinely ambiguous. Cluttering every line hurts readability without adding safety.

Wrap-up and next steps

Type hints give you machine-checked documentation, better autocomplete, and a safety net that grows more valuable as your project does — all without sacrificing Python's dynamism, since the runtime ignores them by default. Start small: annotate your public function signatures, add mypy to your editor and CI, and tighten the configuration as your team gets comfortable.

From here, explore mypy --strict to see how rigorous you can get, look into typing.overload for functions whose return type depends on their arguments, and try Pydantic or dataclasses when you want types validated at runtime too. The investment pays off every time a refactor lights up red before it ever reaches production.