Python Type Hints and mypy: Catching Bugs Before Runtime

Python's type hints (PEP 484) allow you to annotate variables, function parameters, and return types. mypy is a static type checker that analyzes these annotations to catch type errors before your cod

Introduction#

Python’s type hints (PEP 484) allow you to annotate variables, function parameters, and return types. mypy is a static type checker that analyzes these annotations to catch type errors before your code runs. Properly typed Python code is easier to refactor, better documented by the types themselves, and catches an entire class of bugs that would otherwise appear only in production.

Basic Annotations#

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
# Variables
name: str = "Alice"
count: int = 0
ratio: float = 0.95
is_active: bool = True
data: bytes = b"\x00\x01"

# Functions: parameter types and return type
def greet(name: str, formal: bool = False) -> str:
    if formal:
        return f"Good day, {name}."
    return f"Hello, {name}!"

# Return None explicitly
def process(items: list[str]) -> None:
    for item in items:
        print(item)

# Never returns (raises exception or infinite loop)
def fail(message: str) -> None:
    raise RuntimeError(message)

Collections and Generics#

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
from typing import Optional, Union, Any

# Modern syntax (Python 3.10+): use built-in types directly
def get_users(ids: list[int]) -> list[dict[str, str]]:
    ...

def count_words(text: str) -> dict[str, int]:
    ...

def first_or_none(items: list[int]) -> int | None:  # Python 3.10+
    return items[0] if items else None

# For older Python or explicit Optional
def find_user(user_id: int) -> Optional[dict]:  # same as dict | None
    ...

# Union: multiple possible types
def parse_id(value: str | int) -> int:
    return int(value)

# Tuple: fixed-length with typed positions
def divmod_checked(a: int, b: int) -> tuple[int, int]:
    return divmod(a, b)

# Variable-length tuple
def coordinates() -> tuple[float, ...]:
    return (1.0, 2.0, 3.0)

TypedDict for Structured Dicts#

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
from typing import TypedDict, Required, NotRequired

class UserDict(TypedDict):
    id: int
    name: str
    email: str

class UserUpdateDict(TypedDict, total=False):
    name: str       # all optional (total=False)
    email: str

# Mixed required/optional (Python 3.11+)
class OrderDict(TypedDict):
    id: Required[str]
    customer_id: Required[str]
    total: Required[float]
    notes: NotRequired[str]  # optional

def create_user(data: UserDict) -> None:
    print(f"Creating user {data['name']} ({data['email']})")

# mypy catches missing required fields:
# create_user({"id": 1, "name": "Alice"})
# Error: Missing key 'email' for TypedDict "UserDict"

Protocols (Structural Typing)#

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
from typing import Protocol, runtime_checkable

# Protocol: define an interface without inheritance
class Closeable(Protocol):
    def close(self) -> None: ...

class Serializable(Protocol):
    def to_json(self) -> str: ...
    def to_dict(self) -> dict: ...

# Any class with these methods satisfies the protocol
class DatabaseConnection:
    def close(self) -> None:
        print("Closing DB connection")

class FileHandle:
    def close(self) -> None:
        print("Closing file")

def cleanup(resource: Closeable) -> None:
    resource.close()

cleanup(DatabaseConnection())  # works
cleanup(FileHandle())          # works

# Runtime checkable protocol
@runtime_checkable
class HasId(Protocol):
    id: int

class User:
    def __init__(self, id: int):
        self.id = id

print(isinstance(User(1), HasId))  # True

Generic Classes#

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
from typing import Generic, TypeVar

T = TypeVar("T")
K = TypeVar("K")
V = TypeVar("V")

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:
        if not self._items:
            raise IndexError("Stack is empty")
        return self._items.pop()

    def peek(self) -> T | None:
        return self._items[-1] if self._items else None

    def __len__(self) -> int:
        return len(self._items)

# mypy infers the type parameter from usage
int_stack: Stack[int] = Stack()
int_stack.push(1)
int_stack.push(2)
value = int_stack.pop()  # inferred as int

str_stack: Stack[str] = Stack()
str_stack.push("hello")
# int_stack.push("world")  # mypy error: Argument 1 has incompatible type "str"; expected "int"

Callable and Overload#

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
from typing import Callable, overload

# Callable: annotate function arguments
def apply_twice(func: Callable[[int], int], value: int) -> int:
    return func(func(value))

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

result = apply_twice(double, 3)  # 12

# Callable with multiple args
Handler = Callable[[str, int], bool]

# Overload: multiple signatures for the same function
@overload
def parse(value: str) -> str: ...
@overload
def parse(value: int) -> int: ...
@overload
def parse(value: bytes) -> bytes: ...

def parse(value: str | int | bytes) -> str | int | bytes:
    if isinstance(value, bytes):
        return value.decode()
    return value

x: str = parse("hello")    # mypy knows this returns str
y: int = parse(42)         # mypy knows this returns int

mypy Configuration#

1
2
3
4
5
6
7
8
9
10
11
12
13
14
# mypy.ini or pyproject.toml [tool.mypy]
[mypy]
python_version = 3.12
strict = true               # enable all optional strict checks
warn_unused_ignores = true
warn_return_any = true
disallow_untyped_defs = true
disallow_any_unimported = true
check_untyped_defs = true
no_implicit_optional = true

# Per-module overrides for third-party without stubs
[mypy-some_third_party.*]
ignore_missing_imports = true
1
2
3
4
5
6
7
8
9
10
# Run mypy
mypy src/                      # check the entire src/ directory
mypy --strict src/             # strict mode (all checks enabled)
mypy --ignore-missing-imports src/  # skip third-party stubs

# In CI: fail the build on type errors
mypy src/ || exit 1

# Generate type stub for a library
stubgen -p requests -o stubs/

Common Type Patterns in FastAPI#

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
from fastapi import FastAPI, Depends, HTTPException
from pydantic import BaseModel, field_validator
from typing import Annotated

app = FastAPI()

class CreateOrderRequest(BaseModel):
    customer_id: str
    items: list[dict[str, int | str]]  # product_id + quantity
    notes: str | None = None

    @field_validator("items")
    @classmethod
    def items_not_empty(cls, v: list) -> list:
        if not v:
            raise ValueError("Items cannot be empty")
        return v

class OrderResponse(BaseModel):
    id: str
    customer_id: str
    status: str
    total: float

# Dependency injection with types
async def get_current_user_id(token: str) -> int:
    return verify_jwt(token)

CurrentUser = Annotated[int, Depends(get_current_user_id)]

@app.post("/orders", response_model=OrderResponse, status_code=201)
async def create_order(
    request: CreateOrderRequest,
    user_id: CurrentUser,
) -> OrderResponse:
    order = await process_order(request, user_id)
    return OrderResponse(
        id=order.id,
        customer_id=order.customer_id,
        status="confirmed",
        total=order.total,
    )

Conclusion#

Type hints transform Python from a dynamically typed language into one where a significant class of bugs — wrong argument types, missing return values, None dereferences — are caught at development time rather than in production. mypy --strict is the recommended starting point for new projects. For existing codebases, add types incrementally: start with the most critical modules, use # type: ignore sparingly as a temporary escape hatch, and enable stricter checks as coverage improves. The investment pays dividends in refactoring confidence and documentation clarity.

Contents