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: intare parameter type annotations.-> floatis the return type annotation.customer_name: strannotates 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 toUnion[Customer, None].- If you call
customer.emailwithout checking forNonefirst, mypy reportsItem "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: afloatfollowed by anint.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 fromtyping. Usingtyping.List/typing.Dictis 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 infersT = str, so the return type isstr. - When calling with
List[int],T = int. This way mypy knows the exact return type for each call, rather than justAny.
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 = Truerequires all functions to have type hints, avoiding oversights.warn_return_any = Truewarns when a function implicitly returnsAny.ignore_missing_imports = Trueis 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 returningCustomer, 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
Anyeverywhere: declaringdef process(data: Any) -> Anydisables 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 withignore_missing_importsor installtypes-<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.