Python Type Hints Complete Guide — mypy, Protocol, TypeVar

Complete guide to Python type hints: mypy, Protocol, TypeVar, Generic, and Annotated. Master static typing in Python 3.10+ with real-world examples.

Why Type Hints Matter

Python's dynamic typing is great for rapid prototyping, but as codebases grow it becomes a liability. Type hints (introduced in PEP 484, Python 3.5) give you the best of both worlds: runtime flexibility with compile-time safety via static analysis tools like mypy and pyright.

Type hints are purely optional annotations — they don't affect runtime behavior. But they enable IDEs to provide accurate autocomplete, catch bugs before deployment, and serve as machine-verified documentation.

Basic Syntax

Function signatures and variable annotations are the most common use cases:

from typing import Optional

def greet(name: str, times: int = 1) -> str:
    return (f"Hello, {name}!\n") * times

# Variable annotations
user_id: int = 42
maybe_name: Optional[str] = None  # Same as str | None in Python 3.10+

# Python 3.10+ union syntax (preferred)
def process(value: int | str | None) -> str:
    if value is None:
        return "empty"
    return str(value)

Collection Types

Python 3.9+ allows using built-in types directly as generics. For older versions, import from typing:

# Python 3.9+ (preferred)
def process_users(ids: list[int]) -> dict[int, str]:
    return {uid: f"user_{uid}" for uid in ids}

def get_coordinates() -> tuple[float, float]:
    return (40.7128, -74.0060)

# Nested generics
matrix: list[list[int]] = [[1, 2], [3, 4]]
registry: dict[str, list[tuple[int, str]]] = {}

TypeVar — Generic Functions

TypeVar lets you write functions that work on any type while preserving type relationships:

from typing import TypeVar, Sequence

T = TypeVar("T")

def first(items: Sequence[T]) -> T:
    return items[0]

# mypy knows: first([1, 2, 3]) returns int, first(["a", "b"]) returns str

# Bounded TypeVar — T must be a number type
Number = TypeVar("Number", int, float)

def double(value: Number) -> Number:
    return value * 2

Protocol — Structural Subtyping

Protocol enables structural subtyping (duck typing with type safety) — any class with the right methods satisfies the protocol, without explicit inheritance:

from typing import Protocol, runtime_checkable

@runtime_checkable
class Drawable(Protocol):
    def draw(self) -> None: ...
    def resize(self, factor: float) -> None: ...

class Circle:
    def draw(self) -> None:
        print("Drawing circle")
    def resize(self, factor: float) -> None:
        self.radius *= factor

# Circle satisfies Drawable without inheriting from it
def render(shape: Drawable) -> None:
    shape.draw()

render(Circle())   # OK
isinstance(Circle(), Drawable)  # True (requires @runtime_checkable)

Generic Classes

Use Generic[T] to create typed container classes:

from typing import Generic, TypeVar, Iterator

T = TypeVar("T")

class Stack(Generic[T]):
    def __init__(self) -> None:
        self._items: list[T] = []

    def push(self, item: T) -> None:
        self._items.append(item)

    def pop(self) -> T:
        return self._items.pop()

    def __iter__(self) -> Iterator[T]:
        return iter(self._items)

# mypy tracks the type parameter
int_stack: Stack[int] = Stack()
int_stack.push(42)
int_stack.push("hello")  # mypy error: incompatible type "str"; expected "int"

TypedDict — Typed Dictionaries

When working with dictionaries that have a fixed schema (common with JSON APIs), TypedDict provides type safety:

from typing import TypedDict, NotRequired

class UserResponse(TypedDict):
    id: int
    name: str
    email: str
    role: NotRequired[str]  # Optional key

user: UserResponse = {"id": 1, "name": "Alice", "email": "[email protected]"}
# Validate API JSON using DevKits JSON Formatter at /tools/json-formatter.html
print(user["name"])  # mypy knows this is str

Literal and Final

from typing import Literal, Final

Direction = Literal["north", "south", "east", "west"]

def move(direction: Direction, steps: int) -> None:
    print(f"Moving {steps} steps {direction}")

move("north", 5)   # OK
move("up", 5)      # mypy error: Argument "up" not in Literal

MAX_RETRIES: Final = 3
MAX_RETRIES = 5  # mypy error: Cannot assign to final name

Running mypy

pip install mypy

# Check with strict mode
mypy --strict app.py

# mypy.ini configuration
[mypy]
python_version = 3.11
strict = True
ignore_missing_imports = True

Frequently Asked Questions

Do type hints slow down Python?

No — type hints are ignored at runtime by default. Use from __future__ import annotations to defer evaluation entirely for performance-critical code.

Should I use mypy or pyright?

Both are excellent. mypy is the original and most battle-tested. pyright (used by Pylance in VS Code) is faster and stricter. For new projects, pyright in strict mode catches more issues. Use mypy for CI pipelines.

What is Optional[X] vs X | None?

Optional[X] and X | None are identical. The union syntax (Python 3.10+) is cleaner and preferred in modern code.

For testing regex patterns used in type validators, try the DevKits Regex Tester. To format and validate JSON schemas, use the DevKits JSON Formatter.