}

Python Type Hints and mypy: Write Self-Documenting Code (2026)

Python Type Hints and mypy: Write Self-Documenting Code (2026)

Python's dynamic typing is one of its strengths — you can prototype quickly without declaring types for everything. But as codebases grow, the absence of type information becomes a liability. You forget what a function returns, what shape a dict has, or whether a value can be None. You discover these bugs at runtime, often in production.

Type hints, introduced in Python 3.5 and steadily improved since, let you annotate your code without changing how it executes. Run mypy over the annotated code and it catches type errors before your tests even run. FastAPI generates OpenAPI documentation from type hints. Pydantic v2 uses them for runtime validation. Your IDE provides accurate autocomplete.

This tutorial covers type hints from basic to advanced, with practical mypy usage and integration with modern Python frameworks.


Why Type Hints

Four concrete benefits:

1. IDE autocomplete that actually works. With def process(user: User) -> dict[str, int]:, your IDE knows the exact shape of the return value. Without annotations, it guesses.

2. mypy catches bugs before runtime. Passing a None where a str is expected, calling a method that does not exist on a type, returning the wrong type — mypy flags all of these statically.

3. Living documentation. Function signatures with type hints tell the next developer (or future you) what the function accepts and returns without reading the body.

4. Framework integration. FastAPI generates API docs automatically from annotated function parameters. Pydantic v2 uses class-level annotations for validation models. Both require type hints to function.


Basic Types

Python's built-in types are used directly as annotations:

def greet(name: str) -> str:
    return f"Hello, {name}"

def add(x: int, y: int) -> int:
    return x + y

def is_valid(value: float) -> bool:
    return value > 0.0

# Variables can also be annotated
count: int = 0
label: str = "active"
ratio: float = 3.14

The -> ReturnType in the function signature specifies the return type. Use -> None for functions that do not return a value.


Collections with Generics

Since Python 3.9, built-in collection types support generic parameters directly:

# List of integers
def get_scores() -> list[int]:
    return [85, 92, 78, 95]

# Dict mapping strings to any value
from typing import Any

def get_config() -> dict[str, Any]:
    return {"host": "localhost", "port": 5432, "debug": True}

# Tuple with fixed types
def get_point() -> tuple[int, int]:
    return (10, 20)

# Variable-length tuple of a single type
def get_ids() -> tuple[int, ...]:
    return (1, 2, 3, 4, 5)

# Set of strings
def get_tags() -> set[str]:
    return {"python", "web", "api"}

Before Python 3.9, you had to import these from typing: List[int], Dict[str, Any], Tuple[int, int]. In 2026, target Python 3.10+ and use the built-in syntax.


Optional and Union Types

Optional[str] means the value is either a str or None. The Python 3.10+ syntax uses the | operator:

# Python 3.10+ syntax (preferred)
def find_user(user_id: int) -> str | None:
    if user_id == 0:
        return None
    return f"user_{user_id}"

# Equivalent pre-3.10 syntax
from typing import Optional
def find_user_old(user_id: int) -> Optional[str]:
    ...

# Union of multiple types
def process(value: int | str | float) -> str:
    return str(value)

# mypy will catch this error:
result = find_user(42)
print(result.upper())  # ERROR: result might be None
# Fix: check first
if result is not None:
    print(result.upper())  # OK: mypy narrows the type

mypy performs type narrowing — inside the if result is not None: block, it knows result is str, not str | None.


TypedDict: Typed Dicts with Known Keys

TypedDict lets you annotate dictionaries where you know the exact keys and their types — common when working with JSON API responses or configuration dictionaries.

from typing import TypedDict

class UserRecord(TypedDict):
    id: int
    name: str
    email: str
    active: bool

class UserRecordPartial(TypedDict, total=False):
    # total=False makes all keys optional
    name: str
    email: str

def get_user(user_id: int) -> UserRecord:
    return {
        "id": user_id,
        "name": "Alice",
        "email": "[email protected]",
        "active": True,
    }

user = get_user(1)
print(user["name"])          # OK
print(user["nonexistent"])   # mypy ERROR: TypedDict has no key 'nonexistent'

Dataclasses with Type Annotations

Dataclasses use field annotations directly:

from dataclasses import dataclass, field

@dataclass
class Product:
    id: int
    name: str
    price: float
    tags: list[str] = field(default_factory=list)
    description: str | None = None

    def discounted_price(self, pct: float) -> float:
        return self.price * (1 - pct / 100)

p = Product(id=1, name="Widget", price=9.99)
print(p.discounted_price(10))   # 8.991
print(p.nonexistent)            # mypy ERROR: has no attribute 'nonexistent'

Dataclasses generate __init__, __repr__, and __eq__ automatically from the annotated fields. mypy type-checks the generated __init__, so passing the wrong type to the constructor is caught statically.


TypeVar and Generic Classes

TypeVar creates a type variable — a placeholder that gets resolved to a specific type when the function or class is used. Use it for functions that preserve the type of their argument:

from typing import TypeVar, Generic

T = TypeVar("T")

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

result = first([1, 2, 3])       # result: int
result2 = first(["a", "b"])     # result2: str

# Generic class
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()

s: Stack[int] = Stack()
s.push(42)
s.push("oops")   # mypy ERROR: str vs int

Protocol: Structural Subtyping

Protocol defines an interface by the methods a type must have — duck typing with type safety. No inheritance required; a class satisfies a Protocol if it has the right methods.

from typing import Protocol

class Drawable(Protocol):
    def draw(self) -> None: ...
    def get_area(self) -> float: ...

class Circle:
    def __init__(self, radius: float) -> None:
        self.radius = radius

    def draw(self) -> None:
        print(f"Circle r={self.radius}")

    def get_area(self) -> float:
        return 3.14159 * self.radius ** 2

class Rectangle:
    def __init__(self, w: float, h: float) -> None:
        self.w, self.h = w, h

    def draw(self) -> None:
        print(f"Rect {self.w}x{self.h}")

    def get_area(self) -> float:
        return self.w * self.h

def render_all(shapes: list[Drawable]) -> None:
    for shape in shapes:
        shape.draw()

# Both Circle and Rectangle satisfy Drawable without inheriting from it
render_all([Circle(5.0), Rectangle(3.0, 4.0)])  # OK

Protocols are the correct way to write type-safe code against interfaces you do not own — standard library types, third-party objects, or any class you cannot modify.


Running mypy

Install and run:

pip install mypy

# Check a directory
mypy src/

# Ignore missing stubs for packages without type info
mypy src/ --ignore-missing-imports

# Strict mode: enables all optional checks
mypy src/ --strict

Configuring mypy in pyproject.toml

[tool.mypy]
python_version = "3.12"
strict = true
ignore_missing_imports = true
warn_return_any = true
warn_unused_ignores = true

Common mypy Errors and Fixes

# ERROR: Item "None" of "str | None" has no attribute "upper"
def bad(s: str | None) -> str:
    return s.upper()  # mypy: s might be None

# FIX 1: Guard with None check
def good1(s: str | None) -> str:
    if s is None:
        return ""
    return s.upper()

# FIX 2: Use assert (for functions that should never get None)
def good2(s: str | None) -> str:
    assert s is not None
    return s.upper()

# ERROR: Incompatible return value type
def get_count() -> int:
    return "five"  # mypy: expected int, got str

# ERROR: Argument of type "str" cannot be assigned to "int"
def add(x: int, y: int) -> int:
    return x + y

add(1, "2")  # mypy: str vs int

To suppress a specific mypy error on a line:

result = some_dynamic_function()  # type: ignore[no-any-return]

Use # type: ignore sparingly — it silences the checker entirely on that line.


Pydantic v2 Integration

Pydantic v2 uses type hints to define validation models. The annotations drive both the schema and the runtime validation:

from pydantic import BaseModel, EmailStr, field_validator

class User(BaseModel):
    id: int
    name: str
    email: EmailStr
    age: int | None = None
    tags: list[str] = []

    @field_validator("age")
    @classmethod
    def age_must_be_positive(cls, v: int | None) -> int | None:
        if v is not None and v < 0:
            raise ValueError("age must be positive")
        return v

# Pydantic validates at construction time
user = User(id=1, name="Alice", email="[email protected]")
print(user.model_dump())

# Type error caught at runtime by Pydantic
try:
    bad = User(id="not-an-int", name="Bob", email="bad-email")
except Exception as e:
    print(e)   # ValidationError with field-level details

mypy understands Pydantic models natively (via the mypy-pydantic plugin):

pip install pydantic mypy
# Add to pyproject.toml:
# [tool.mypy]
# plugins = ["pydantic.mypy"]

FastAPI Integration

FastAPI uses type hints on path operation functions to generate request parsing, validation, and OpenAPI documentation automatically:

from fastapi import FastAPI, HTTPException
from pydantic import BaseModel

app = FastAPI()

class Item(BaseModel):
    name: str
    price: float
    available: bool = True

@app.get("/items/{item_id}", response_model=Item)
async def get_item(item_id: int) -> Item:
    # item_id is already an int (FastAPI converts and validates from path)
    if item_id == 0:
        raise HTTPException(status_code=404, detail="Not found")
    return Item(name="Widget", price=9.99)

@app.post("/items/", response_model=Item)
async def create_item(item: Item) -> Item:
    # item is parsed and validated from the request body
    return item

FastAPI reads the response_model=Item annotation to generate the response schema in /docs. It reads the item: Item parameter to parse the JSON body. It reads item_id: int to convert the URL path segment from string to int and validate it.

Type hints in FastAPI are not decoration — they are executable specifications.

Leonardo Lazzaro

Software engineer and technical writer. 10+ years experience in DevOps, Python, and Linux systems.

More articles by Leonardo Lazzaro