Python Type Hints & Mypy — Static Typing for Better Code
Type hints are the best thing that happened to Python since list comprehensions. They catch bugs at development time, make refactoring safe, and serve as living documentation. But most tutorials stop at def foo(x: int) -> str. This guide covers the patterns you'll actually use in production — including the tricky parts.
The Basics (Quick Refresher)
# Variables
name: str = "Alice"
age: int = 30
score: float = 9.5
active: bool = True
# Functions
def greet(name: str, loud: bool = False) -> str:
msg = f"Hello, {name}!"
return msg.upper() if loud else msg
# Collections (Python 3.9+, use built-in types)
names: list[str] = ["Alice", "Bob"]
scores: dict[str, float] = {"Alice": 9.5, "Bob": 8.2}
coordinates: tuple[float, float] = (40.7, -74.0)
unique_ids: set[int] = {1, 2, 3}
# Optional (value or None)
from typing import Optional
# Python 3.10+ — use X | None instead
def find_user(user_id: int) -> dict | None:
return db.get(user_id) # might return None
# Union types
def process(value: str | int | float) -> str:
return str(value)
Callable Types
For functions as parameters — common in Strategy and Observer patterns.
from collections.abc import Callable
# Function that takes (str, int) and returns bool
def apply_filter(
items: list[str],
predicate: Callable[[str], bool],
) -> list[str]:
return [item for item in items if predicate(item)]
# Usage
long_names = apply_filter(
["Al", "Alexander", "Bob", "Benjamin"],
lambda name: len(name) > 3,
)
# Callable with no specific signature
from typing import Any
def register(callback: Callable[..., Any]) -> None:
...
# More precise: ParamSpec (Python 3.10+)
from typing import ParamSpec, TypeVar
P = ParamSpec("P")
R = TypeVar("R")
def with_logging(fn: Callable[P, R]) -> Callable[P, R]:
"""Decorator that preserves the wrapped function's signature."""
def wrapper(*args: P.args, **kwargs: P.kwargs) -> R:
print(f"Calling {fn.__name__}")
return fn(*args, **kwargs)
return wrapper
@with_logging
def add(a: int, b: int) -> int:
return a + b
# mypy knows add(a: int, b: int) -> int — signature preserved!
Generics — Write Type-Safe Reusable Code
from typing import TypeVar, Generic
T = TypeVar("T")
class Stack(Generic[T]):
"""Type-safe stack — Stack[int] only holds ints."""
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:
if not self._items:
raise IndexError("Stack is empty")
return self._items[-1]
def __len__(self) -> int:
return len(self._items)
# Usage — mypy enforces type consistency
int_stack: Stack[int] = Stack()
int_stack.push(42)
int_stack.push(17)
value: int = int_stack.pop() # mypy knows this is int
str_stack: Stack[str] = Stack()
str_stack.push("hello")
# str_stack.push(42) # mypy error: Argument 1 has incompatible type "int"
Bounded TypeVars
from typing import TypeVar
from datetime import datetime
# T must be a subclass of str or int
Sortable = TypeVar("Sortable", str, int, float)
def max_value(a: Sortable, b: Sortable) -> Sortable:
return a if a >= b else b
# Works
max_value(10, 20) # OK: int
max_value("abc", "xyz") # OK: str
# Bound to a base class
from typing import TypeVar
class Animal:
name: str
class Dog(Animal):
breed: str
A = TypeVar("A", bound=Animal)
def get_name(animal: A) -> str:
return animal.name # mypy knows A has .name
Protocol — Structural Typing (Duck Typing with Safety)
Protocol is Python's answer to interfaces — but based on structure, not inheritance. If it walks like a duck and quacks like a duck, mypy accepts it as a duck.
from typing import Protocol, runtime_checkable
class Renderable(Protocol):
"""Anything with a .render() -> str method."""
def render(self) -> str: ...
class HTMLWidget:
def render(self) -> str:
return "<div>widget</div>"
class MarkdownDoc:
def render(self) -> str:
return "# Document"
class PlainText:
def render(self) -> str:
return "Just text"
def display(item: Renderable) -> None:
"""Accepts ANY object with render() -> str."""
print(item.render())
# All of these work — no inheritance needed!
display(HTMLWidget()) # ✅
display(MarkdownDoc()) # ✅
display(PlainText()) # ✅
# display(42) # ❌ mypy error: int has no render()
# Runtime checking with @runtime_checkable
@runtime_checkable
class Closeable(Protocol):
def close(self) -> None: ...
import io
f = io.StringIO()
print(isinstance(f, Closeable)) # True — StringIO has .close()
TypedDict — Type-Safe Dictionaries
from typing import TypedDict, NotRequired
class UserConfig(TypedDict):
username: str
email: str
theme: NotRequired[str] # Optional key (Python 3.11+)
notifications: NotRequired[bool]
def save_config(config: UserConfig) -> None:
print(f"Saving config for {config['username']}")
# ✅ Valid
save_config({"username": "alice", "email": "a@b.com"})
save_config({"username": "bob", "email": "b@c.com", "theme": "dark"})
# ❌ mypy catches these
# save_config({"username": "alice"}) # Missing required key 'email'
# save_config({"username": "alice", "email": "a@b.com", "age": 30}) # Extra key
# Nested TypedDicts
class Address(TypedDict):
street: str
city: str
country: str
class UserProfile(TypedDict):
name: str
address: Address
tags: list[str]
Literal, Final, and NewType
from typing import Literal, Final, NewType
# Literal — restrict to specific values
def set_log_level(level: Literal["debug", "info", "warning", "error"]) -> None:
print(f"Log level: {level}")
set_log_level("info") # ✅
# set_log_level("verbose") # ❌ mypy error
# Final — constant that can't be reassigned
MAX_RETRIES: Final = 3
API_VERSION: Final[str] = "v2"
# MAX_RETRIES = 5 # ❌ mypy error: Cannot assign to final name
# NewType — create distinct types from existing ones
UserId = NewType("UserId", int)
OrderId = NewType("OrderId", int)
def get_user(user_id: UserId) -> dict:
return {"id": user_id}
uid = UserId(42)
oid = OrderId(42)
get_user(uid) # ✅
# get_user(oid) # ❌ mypy error: expected UserId, got OrderId
# get_user(42) # ❌ mypy error: expected UserId, got int
Overload — Multiple Signatures
from typing import overload
@overload
def process(data: str) -> list[str]: ...
@overload
def process(data: bytes) -> list[bytes]: ...
@overload
def process(data: list[str]) -> str: ...
def process(data):
"""Implementation handles all cases."""
if isinstance(data, str):
return data.split()
elif isinstance(data, bytes):
return data.split(b" ")
elif isinstance(data, list):
return " ".join(data)
raise TypeError(f"Unsupported type: {type(data)}")
# mypy knows the return types!
words: list[str] = process("hello world") # ✅ list[str]
chunks: list[bytes] = process(b"hello world") # ✅ list[bytes]
sentence: str = process(["hello", "world"]) # ✅ str
Pydantic — Runtime Type Validation
Type hints alone are only checked by mypy (at dev time). Pydantic validates at runtime — crucial for API input, config files, and external data. See our FastAPI guide for Pydantic in APIs.
from pydantic import BaseModel, Field, field_validator, model_validator
from datetime import datetime
class CreateUser(BaseModel):
name: str = Field(..., min_length=1, max_length=100)
email: str = Field(..., pattern=r"^[\w.-]+@[\w.-]+\.\w+$")
age: int = Field(..., ge=0, le=150)
role: Literal["admin", "user", "viewer"] = "user"
tags: list[str] = []
@field_validator("name")
@classmethod
def name_must_be_titlecase(cls, v: str) -> str:
return v.strip().title()
@field_validator("tags")
@classmethod
def validate_tags(cls, v: list[str]) -> list[str]:
return [tag.lower().strip() for tag in v if tag.strip()]
# ✅ Valid — auto-coerces and validates
user = CreateUser(
name=" alice ",
email="alice@example.com",
age=30,
tags=["Python", " FastAPI ", ""],
)
print(user.name) # "Alice" (stripped + titlecased)
print(user.tags) # ["python", "fastapi"] (lowercased, empty removed)
# ❌ Raises ValidationError
try:
CreateUser(name="", email="not-an-email", age=-5)
except Exception as e:
print(e)
# name: String should have at least 1 character
# email: String should match pattern ...
# age: Input should be greater than or equal to 0
Model composition
class Address(BaseModel):
street: str
city: str
country: str = "US"
class Company(BaseModel):
name: str
founded: int
class Employee(BaseModel):
name: str
address: Address
company: Company
start_date: datetime
# Pydantic handles nested validation + JSON serialization
emp = Employee(
name="Bob",
address={"street": "123 Main St", "city": "NYC"}, # dict auto-parsed
company={"name": "Acme", "founded": 2020},
start_date="2026-01-15T09:00:00", # string auto-parsed to datetime
)
print(emp.model_dump_json(indent=2))
# Full JSON with nested objects, dates as ISO strings
Mypy Configuration
# pyproject.toml — recommended mypy config
[tool.mypy]
python_version = "3.12"
strict = true # Enable all strict checks
warn_return_any = true
warn_unused_configs = true
disallow_untyped_defs = true # Every function must have type hints
disallow_any_generics = true # No bare list, dict — must be list[int], dict[str, int]
check_untyped_defs = true
no_implicit_optional = true # def f(x: str = None) is an error — use str | None
# Per-module overrides
[[tool.mypy.overrides]]
module = "tests.*"
disallow_untyped_defs = false # Tests can be less strict
[[tool.mypy.overrides]]
module = "third_party_lib.*"
ignore_missing_imports = true # No stubs available
# Run mypy
pip install mypy
# Check a file
mypy app/main.py
# Check entire project
mypy app/
# With useful flags
mypy app/ --show-error-codes --pretty
# Example output:
# app/service.py:42: error: Argument 1 to "process" has
# incompatible type "int"; expected "str" [arg-type]
# app/service.py:55: error: Missing return statement [return]
Common mypy errors and fixes
# 1. "Item X has no attribute Y" — narrow the type
def handle(value: str | int) -> str:
# ❌ value.upper() — int has no .upper()
if isinstance(value, str):
return value.upper() # ✅ mypy knows it's str here
return str(value)
# 2. "Incompatible return type" — check all paths
def find(items: list[str], target: str) -> int:
for i, item in enumerate(items):
if item == target:
return i
return -1 # ✅ Don't forget the else path
# 3. "Cannot infer type" — add annotation
# ❌ data = []
data: list[str] = [] # ✅
# 4. "Missing type annotation for **kwargs"
def config(**kwargs: str) -> None: # ✅ All kwargs are str
...
# 5. Ignoring a line (last resort)
result = sketchy_function() # type: ignore[no-untyped-call]
Type Hints Cheat Sheet
| Type | Example | Since |
|---|---|---|
| Basic | int, str, float, bool, bytes, None | 3.5 |
| List | list[str] | 3.9 |
| Dict | dict[str, int] | 3.9 |
| Tuple (fixed) | tuple[str, int, float] | 3.9 |
| Tuple (variable) | tuple[str, ...] | 3.9 |
| Set | set[int] | 3.9 |
| Union | str | int | 3.10 |
| Optional | str | None | 3.10 |
| Callable | Callable[[int, str], bool] | 3.9 |
| TypeVar | T = TypeVar("T") | 3.5 |
| Protocol | class P(Protocol): ... | 3.8 |
| TypedDict | class TD(TypedDict): ... | 3.8 |
| Literal | Literal["a", "b"] | 3.8 |
| Final | MAX: Final = 100 | 3.8 |
| NewType | UserId = NewType("UserId", int) | 3.5 |
| Self | def copy(self) -> Self | 3.11 |
| ParamSpec | P = ParamSpec("P") | 3.10 |
| type statement | type Vector = list[float] | 3.12 |
🚀 Want production Python templates with full type coverage and mypy CI setup?
Related Articles
- Build a REST API with FastAPI — Pydantic models and type-safe API endpoints
- Python Design Patterns — Protocol and generics in pattern implementations
- Python Testing Guide — type-aware testing with mypy and pytest
- Python Decorators Deep Dive — ParamSpec for type-safe decorators
- Python Packaging Guide — ship type stubs with your package
Need help adding type safety to your Python project? I build well-typed APIs, automation tools, and data pipelines. Reach out on Telegram →