Python Type Hints and Static Typing
Write self-documenting, safer Python code using type annotations, generics, Protocol, TypeVar, and runtime validation with Pydantic.
Python’s type hint system (introduced in PEP 484) allows you to annotate variables, function parameters, and return values with types. Static analysers like mypy and pyright use these hints to catch bugs before your code runs.
Basic Annotations
Type annotations are the foundation of static typing in Python. They serve two purposes: they tell a static analyser what types to expect so it can flag mismatches, and they serve as inline documentation that makes function signatures self-explanatory without requiring a docstring. Annotations have zero runtime cost — the interpreter records them in __annotations__ but never enforces them unless you add a runtime validator.
# Variable annotations describe what a variable is allowed to hold
name: str = "Alice"
age: int = 30
scores: list[float] = [9.5, 8.2, 7.8]
config: dict[str, str] = {"env": "production"}
# Function annotations describe inputs and the return value
# mypy will flag any call site that passes the wrong type
def greet(name: str, greeting: str = "Hello") -> str:
return f"{greeting}, {name}!"
# -> None makes it explicit that this function has no meaningful return value
def log_event(message: str) -> None:
print(f"[LOG] {message}")
Union Types and Optional
Real-world functions often accept more than one type — a value that could be a string or an integer, or a result that could be None when not found. Union types let you express this precisely. In Python 3.10+ you write X | Y directly; in earlier versions you use Union[X, Y] from typing. Optional[X] is shorthand for X | None and is very common for function return values that signal “not found” with None.
from typing import Optional
# Python 3.10+ — the | syntax is concise and readable
def parse_int(value: str | int) -> int | None:
try:
return int(value)
except (ValueError, TypeError):
return None # explicitly typed as a valid return value
# Pre-3.10 equivalent using Optional from typing
def parse_int_compat(value: str) -> Optional[int]:
try:
return int(value)
except ValueError:
return None
Collection Type Hints
When annotating collection parameters, prefer abstract types from typing over concrete ones like list or dict. A function that only needs to iterate over values should accept Iterable, not list — this makes it work with lists, tuples, generators, and any other iterable without changes. Using the most general type that satisfies your needs is called the Liskov substitution principle, and it makes your functions far more reusable.
from typing import Sequence, Mapping, Iterable
# Sequence accepts list, tuple, str — anything ordered and subscriptable
def sum_scores(scores: Sequence[float]) -> float:
return sum(scores)
# Mapping accepts dict, OrderedDict, defaultdict, and any dict-like object
def print_headers(headers: Mapping[str, str]) -> None:
for key, value in headers.items():
print(f"{key}: {value}")
# Iterable is the broadest — accepts any object you can loop over
def process_items(items: Iterable[str]) -> list[str]:
return [item.strip().lower() for item in items]
# Callable describes a function as a value — useful for callbacks and higher-order functions
from typing import Callable
# Callable[[arg_type, ...], return_type]
def apply(func: Callable[[int, int], int], a: int, b: int) -> int:
return func(a, b)
result = apply(lambda x, y: x + y, 3, 4) # 7
TypeVar — Generic Functions
TypeVar solves a specific problem: how do you annotate a function that returns the same type it receives, without losing type information? Without TypeVar, you’d have to write -> Any, which defeats the purpose of typing. With TypeVar, the type checker tracks the relationship between input and output — if you pass a list[int], it knows the return is int | None, not just Any.
from typing import TypeVar, Sequence
T = TypeVar("T") # T is a stand-in for "whatever concrete type the caller uses"
def first(items: Sequence[T]) -> T | None:
"""Return the first element, or None if empty."""
return items[0] if items else None
# mypy resolves T to int here — the return type is int | None, not Any
x: int | None = first([1, 2, 3])
# mypy resolves T to str here — the return type is str | None
s: str | None = first(["a", "b", "c"])
Generic Classes
Generic classes extend the TypeVar concept to data structures. A Stack that stores integers is a different type from a Stack that stores strings, and the type checker should enforce that you don’t mix them. Inheriting from Generic[T] makes the class parameterizable so callers can write Stack[int] or Stack[str], and the type checker will verify that all methods respect the declared element type.
from typing import Generic, TypeVar
T = TypeVar("T")
class Stack(Generic[T]):
def __init__(self) -> None:
self._items: list[T] = [] # T will be resolved when Stack is instantiated
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:
return self._items[-1]
def __len__(self) -> int:
return len(self._items)
# mypy now knows this stack only holds ints — pushing a str would be a type error
stack: Stack[int] = Stack()
stack.push(1)
stack.push(2)
print(stack.pop()) # 2 — typed as int, not Any
Protocol — Structural Subtyping
Abstract Base Classes require explicit inheritance, which creates tight coupling between unrelated modules. Protocol takes the opposite approach: any class that implements the required methods automatically satisfies the protocol, no inheritance needed. This is Python’s way of formalizing duck typing — the principle that “if it walks like a duck and quacks like a duck, it is a duck.” It is ideal for defining interfaces between components that shouldn’t depend on each other directly.
from typing import Protocol, runtime_checkable
# @runtime_checkable allows isinstance() checks at runtime, not just static analysis
@runtime_checkable
class Drawable(Protocol):
def draw(self) -> str:
... # protocol bodies use ... — no implementation needed
class Circle:
def draw(self) -> str:
return "○"
class Square:
def draw(self) -> str:
return "□"
class TextLabel:
def draw(self) -> str:
return "[Label]"
# render_all accepts any object with a .draw() method — no import of Drawable required
def render_all(shapes: list[Drawable]) -> None:
for shape in shapes:
print(shape.draw())
# None of these inherit from Drawable — they satisfy it structurally
render_all([Circle(), Square(), TextLabel()])
# Runtime check works because of @runtime_checkable
print(isinstance(Circle(), Drawable)) # True
TypedDict — Typed Dictionaries
Plain dictionaries give the type checker no information about which keys exist or what types their values have. TypedDict fixes this by defining the exact shape of a dictionary. The type checker will flag accesses to undefined keys, catch type mismatches in values, and even distinguish between required and optional keys using NotRequired. It is a lightweight alternative to a dataclass when you need to stay compatible with code that expects plain dicts.
from typing import TypedDict, NotRequired
class UserRecord(TypedDict):
id: int
name: str
email: str
role: NotRequired[str] # this key may be absent — the checker won't require it
def create_user(data: UserRecord) -> None:
print(f"Creating user: {data['name']}")
# The checker verifies all required keys are present and have the right types
create_user({"id": 1, "name": "Alice", "email": "[email protected]"})
Pydantic — Runtime Validation
Type hints are erased at runtime, so they cannot protect you from bad data coming in from an HTTP request, a config file, or a database. Pydantic bridges this gap: it uses your type annotations to validate and coerce data at runtime, raising a clear ValidationError when something doesn’t match. This is the standard approach for validating API request bodies in FastAPI and for parsing configuration files. Install with pip install pydantic.
from pydantic import BaseModel, field_validator
from pydantic import Field
class UserRequest(BaseModel):
# Field() adds validation constraints on top of the type hint
name: str = Field(min_length=2, max_length=100)
age: int = Field(ge=0, le=150) # ge = greater-or-equal, le = less-or-equal
email: str
@field_validator("name")
@classmethod
def name_must_not_contain_numbers(cls, v: str) -> str:
if any(char.isdigit() for char in v):
raise ValueError("Name must not contain numbers")
return v.strip() # validators can also transform the value
# Valid input — Pydantic coerces compatible types automatically
user = UserRequest(name="Alice", age=30, email="[email protected]")
print(user.model_dump()) # {'name': 'Alice', 'age': 30, 'email': '[email protected]'}
# Invalid input — raises ValidationError with a clear description of every problem
try:
bad = UserRequest(name="A", age=-5, email="not-an-email")
except Exception as e:
print(e)
Type Narrowing
Static type checkers track the possible types of a variable through control flow — a technique called type narrowing. When you check if isinstance(value, int):, the checker knows that inside that branch value is definitely an int, not a str or None. This means you can write safe, branch-specific code without casts, and the checker will still verify correctness in each branch independently.
def process(value: int | str | None) -> str:
if value is None:
return "no value" # checker knows: value is None in this branch
if isinstance(value, int):
return f"integer: {value}" # checker knows: value is int in this branch
return f"string: {value}" # checker knows: value is str here (None and int are ruled out)
Running mypy
Once your code is annotated, mypy is the standard tool for checking it. Running it regularly — ideally in CI — catches type errors before they reach production. The --strict flag is the recommended starting point for new projects: it enables every optional check and ensures no function is left unannotated.
pip install mypy
mypy your_script.py --strict
The --strict flag enables all optional checks including --disallow-untyped-defs, --no-implicit-optional, and --warn-return-any.