Type hints in Python and mypy checking

1. Overview

Python is a dynamically-typed language, meaning you don’t need to declare data types beforehand. This is flexible but easily causes runtime errors as projects grow larger. Type hints (PEP 484) allow you to annotate data types without changing runtime behavior, while mypy reads those annotations to detect type errors as you write code.

After reading this article, you’ll know how to write type hints for variables, functions, classes, use Optional, Union, TypedDict, generics, and integrate mypy into your code-checking workflow.

2. Basic type hints

2.1. Declaring types for variables and functions

def calculate_total(price: float, quantity: int) -> float:
    return price * quantity

customer_name: str = "Nguyen Van A"
total: float = calculate_total(price=15.5, quantity=3)
print(total)
  • price: float, quantity: int are parameter type annotations.
  • -> float is the return type annotation.
  • customer_name: str annotates the variable, helping IDEs provide hints and mypy to check.
46.5

Note: type hints do NOT enforce types at runtime. If you pass calculate_total("10", 2), Python runs fine because the interpreter ignores the annotation — only tools like mypy will report the error.

2.2. Installing mypy

pip install mypy

Check the file:

mypy order_service.py

If there are no errors, mypy prints:

Success: no issues found in 1 source file

3. Optional and Union for values that can be None

Many functions return None when they don’t find data. Use Optional to make this explicit.

from typing import Optional

class Customer:
    def __init__(self, customer_id: int, email: str) -> None:
        self.customer_id = customer_id
        self.email = email

def find_customer(customer_id: int) -> Optional[Customer]:
    database = {1: Customer(1, "a@example.com")}
    return database.get(customer_id)

customer = find_customer(2)
if customer is not None:
    print(customer.email)
  • Optional[Customer] is equivalent to Union[Customer, None].
  • If you call customer.email without checking for None first, mypy reports Item "None" of "Optional[Customer]" has no attribute "email".
from typing import Union

def format_id(value: Union[int, str]) -> str:
    return str(value).zfill(6)

Union[int, str] allows the parameter to be either int or str. Starting from Python 3.10, you can write more concisely using int | str instead of Union[int, str], and Customer | None instead of Optional[Customer].

4. Collection types: list, dict, tuple

from typing import Dict, List, Tuple

def summarize_orders(order_totals: List[float]) -> Tuple[float, int]:
    return sum(order_totals), len(order_totals)

def build_price_table(items: Dict[str, float]) -> Dict[str, float]:
    return {name: price * 1.1 for name, price in items.items()}
  • List[float] means a list of floats.
  • Tuple[float, int] means a tuple with exactly 2 elements: a float followed by an int.
  • Dict[str, float] is a dict with string keys and float values.

Note: Starting from Python 3.9, you can use list[float], dict[str, float] directly without importing from typing. Using typing.List/typing.Dict is mainly for compatibility with Python 3.8 and earlier.

5. TypedDict for structured dict data

When working with JSON or dicts with a fixed schema, TypedDict helps mypy check each key.

from typing import TypedDict

class OrderPayload(TypedDict):
    order_id: int
    customer_email: str
    total: float

def create_order(payload: OrderPayload) -> str:
    return f"Order #{payload['order_id']} - {payload['customer_email']} - {payload['total']}"

payload: OrderPayload = {
    "order_id": 101,
    "customer_email": "b@example.com",
    "total": 250.0,
}
print(create_order(payload))

If you’re missing the total key or misspell it as "totl", mypy reports:

error: Missing key "total" for TypedDict "OrderPayload"

This is a major advantage over using Dict[str, Any] — you control the data structure while keeping regular Python dicts at runtime.

6. Generics with TypeVar

When writing functions or classes that work with multiple data types, use TypeVar to maintain the relationship between input and output types.

from typing import TypeVar, List

T = TypeVar("T")

def first_item(items: List[T]) -> T:
    return items[0]

first_name: str = first_item(["Hung", "Lan"])
first_number: int = first_item([10, 20, 30])
  • TypeVar("T") creates a type placeholder.
  • When calling first_item(["Hung", "Lan"]), mypy infers T = str, so the return type is str.
  • When calling with List[int], T = int. This way mypy knows the exact return type for each call, rather than just Any.

7. Classes and Protocol instead of interfaces

Python doesn’t have interfaces like Java, but Protocol (PEP 544) enables structural typing — checking “has the right methods” instead of requiring inheritance.

from typing import Protocol

class PaymentGateway(Protocol):
    def charge(self, amount: float) -> bool:
        ...

class VnPayGateway:
    def charge(self, amount: float) -> bool:
        print(f"Charging {amount} via VNPay")
        return True

def process_payment(gateway: PaymentGateway, amount: float) -> bool:
    return gateway.charge(amount)

process_payment(VnPayGateway(), 199000.0)

VnPayGateway doesn’t need to inherit from PaymentGateway — it just needs a charge(self, amount: float) -> bool method for mypy to accept it as satisfying the Protocol. This is a very “Pythonic” approach, preserving duck typing while still enabling static checking.

8. Configuring mypy for your project

Add the dependency to requirements-dev.txt:

mypy==1.11.2

Create a mypy.ini configuration file in your project root:

[mypy]
python_version = 3.12
disallow_untyped_defs = True
warn_return_any = True
warn_unused_ignores = True
ignore_missing_imports = True
  • disallow_untyped_defs = True requires all functions to have type hints, avoiding oversights.
  • warn_return_any = True warns when a function implicitly returns Any.
  • ignore_missing_imports = True is useful when using third-party libraries without type stubs.

Run type checking on your entire project:

mypy src/

9. Writing tests for behavior with type hints

Type hints don’t replace tests, but make tests clearer about input/output types. Use pytest:

<!-- requirements-dev.txt equivalent format, for illustration only -->
pytest==8.3.2
from order_service import calculate_total

def test_calculate_total_returns_correct_sum() -> None:
    result = calculate_total(price=20.0, quantity=5)

    assert result == 100.0

def test_calculate_total_with_zero_quantity() -> None:
    result = calculate_total(price=50.0, quantity=0)

    assert result == 0.0

Run tests:

pytest -v
test_order_service.py::test_calculate_total_returns_correct_sum PASSED
test_order_service.py::test_calculate_total_with_zero_quantity PASSED

You can combine both steps in CI: run mypy first to catch type errors, then run pytest to check business logic. Both tools complement each other — mypy catches “wrong data type” errors, pytest catches “wrong logic” errors.

10. Common errors and when you don’t need mypy

  • Forgetting to update type hints when changing logic: for example, a function changes from returning Optional[Customer] to always returning Customer, but you forget to update the annotation — mypy won’t report it as wrong if the code still matches the broader old type, leading to inaccurate annotation information.
  • Spreading Any everywhere: declaring def process(data: Any) -> Any disables mypy’s type checking, effectively turning off static typing there.
  • Wrong annotations for third-party libraries without stubs: many older packages haven’t published type stubs, and mypy will report error: Cannot find implementation or library stub. Handle this with ignore_missing_imports or install types-<package> if available.
  • When NOT to use mypy strictly: for small scripts, one-off throwaway scripts, or quick prototypes to test ideas — enforcing full type hints can slow down writing without corresponding value.

Note: mypy only performs static analysis on source code and doesn’t run the program. It can’t catch errors dependent on runtime data, such as whether values read from config files or external APIs have the expected types.

11. Conclusion

Type hints make Python code clearer, easier to maintain, and reduce errors from type confusion, while mypy transforms those annotations into real checking tools rather than just documentation. You should start by gradually applying type hints to public functions, important modules, then expand across the project using the disallow_untyped_defs configuration. Combining mypy in CI with pytest testing is the most practical approach to maintain Python’s flexibility while gaining type-safety close to statically-typed languages.

pythontype-hintsmypystatic-typing