Python Type Hints and Why They Matter
A practical guide to Python type hints — from basics to advanced patterns like Protocols, TypeGuards, and generics. Learn how static typing improves Python codebases.
Python’s dynamic typing is a double-edged sword. It makes prototyping fast but can turn large codebases into guessing games. Type hints, introduced in PEP 484 (Python 3.5), let you add optional static typing while keeping Python’s flexibility. When combined with tools like mypy or pyright, they catch bugs before your code ever runs.
Why Type Hints?
Consider this function:
def process_order(order, discount):
total = order["total"] * (1 - discount)
if order["priority"]:
total *= 0.95
return {"amount": total, "status": "processed"}
What type is order? What keys does it have? What’s discount — a float between 0 and 1, or a percentage? What does the function return? Without reading the implementation line by line, you can’t know.
With type hints:
from typing import TypedDict
class Order(TypedDict):
id: str
total: float
priority: bool
class ProcessedOrder(TypedDict):
amount: float
status: str
def process_order(order: Order, discount: float) -> ProcessedOrder:
total = order["total"] * (1 - discount)
if order["priority"]:
total *= 0.95
return {"amount": total, "status": "processed"}
Now the function is self-documenting. Your IDE autocompletes dict keys. Mypy catches errors statically.
Essential Type Hints
Basic Types
name: str = "Alice"
age: int = 30
price: float = 19.99
active: bool = True
data: bytes = b"raw"
Collections (Python 3.9+)
# Use built-in types directly (no need for typing.List, typing.Dict)
names: list[str] = ["Alice", "Bob"]
scores: dict[str, int] = {"Alice": 100, "Bob": 85}
coordinates: tuple[float, float] = (48.8566, 2.3522)
unique_ids: set[str] = {"a1", "b2", "c3"}
# Variable-length tuples
values: tuple[int, ...] = (1, 2, 3, 4, 5)
Union and Optional
# Python 3.10+ syntax
def find_user(user_id: str) -> User | None:
...
# Equivalent to Optional[User]
def parse_int(value: str) -> int | None:
try:
return int(value)
except ValueError:
return None
# Union of multiple types
def normalize(value: str | int | float) -> str:
return str(value).strip()
Callable Types
from collections.abc import Callable
# A function that takes (str, int) and returns bool
Validator = Callable[[str, int], bool]
def apply_validators(value: str, validators: list[Validator]) -> bool:
return all(v(value, len(value)) for v in validators)
# With default arguments — use Protocol instead
from typing import Protocol
class Formatter(Protocol):
def __call__(self, text: str, *, uppercase: bool = False) -> str: ...
TypedDict: Typed Dictionaries
For structured dictionaries (common with JSON APIs):
from typing import TypedDict, NotRequired
class UserProfile(TypedDict):
id: str
name: str
email: str
avatar: NotRequired[str] # Optional field (Python 3.11+)
def create_user(data: UserProfile) -> None:
print(data["name"]) # ✅ Autocomplete works
print(data["phone"]) # ❌ mypy error: TypedDict has no key 'phone'
Protocols: Structural Subtyping
Protocols define what an object can do without requiring inheritance. This is Python’s answer to interfaces:
from typing import Protocol, runtime_checkable
@runtime_checkable
class Renderable(Protocol):
def render(self) -> str: ...
class MarkdownDoc:
def __init__(self, content: str):
self.content = content
def render(self) -> str:
return f"<p>{self.content}</p>"
class JSONResponse:
def __init__(self, data: dict):
self.data = data
def render(self) -> str:
import json
return json.dumps(self.data)
# Both work — no base class needed
def output(item: Renderable) -> None:
print(item.render())
output(MarkdownDoc("Hello")) # ✅
output(JSONResponse({"ok": True})) # ✅
output("plain string") # ❌ mypy error: str has no render()
Protocols encourage duck typing with safety. If it has a render() method returning str, it’s Renderable.
Generics
Generic Functions
from typing import TypeVar
T = TypeVar("T")
def first(items: list[T]) -> T | None:
return items[0] if items else None
# TypeScript knows the return type:
result = first([1, 2, 3]) # int | None
name = first(["a", "b"]) # str | None
Generic Classes
from typing import Generic, TypeVar
T = TypeVar("T")
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
# Usage
int_stack: Stack[int] = Stack()
int_stack.push(42)
int_stack.push("oops") # ❌ mypy error: expected int, got str
Bounded TypeVars
from typing import TypeVar
from collections.abc import Sized
S = TypeVar("S", bound=Sized)
def longest(a: S, b: S) -> S:
return a if len(a) >= len(b) else b
longest([1, 2], [3, 4, 5]) # ✅ list[int]
longest("abc", "de") # ✅ str
longest(42, 100) # ❌ int has no len()
TypeGuard: Custom Type Narrowing
from typing import TypeGuard
def is_string_list(val: list[object]) -> TypeGuard[list[str]]:
return all(isinstance(item, str) for item in val)
def process(data: list[object]) -> None:
if is_string_list(data):
# mypy knows data is list[str] here
joined = ", ".join(data) # ✅ No error
print(joined)
Literal Types
Constrain values to specific literals:
from typing import Literal
LogLevel = Literal["DEBUG", "INFO", "WARNING", "ERROR"]
def log(message: str, level: LogLevel = "INFO") -> None:
print(f"[{level}] {message}")
log("Starting", "INFO") # ✅
log("Oops", "CRITICAL") # ❌ mypy error
Overloaded Functions
When a function’s return type depends on its arguments:
from typing import overload
@overload
def parse(raw: str, as_list: Literal[True]) -> list[str]: ...
@overload
def parse(raw: str, as_list: Literal[False]) -> str: ...
@overload
def parse(raw: str) -> str: ...
def parse(raw: str, as_list: bool = False) -> str | list[str]:
if as_list:
return raw.split(",")
return raw.strip()
result1 = parse("a,b,c", as_list=True) # list[str]
result2 = parse("hello") # str
Setting Up Mypy
Install and configure mypy for strict checking:
# pyproject.toml
[tool.mypy]
python_version = "3.12"
strict = true
warn_return_any = true
warn_unused_configs = true
disallow_untyped_defs = true
disallow_any_generics = true
check_untyped_defs = true
no_implicit_optional = true
warn_redundant_casts = true
warn_unused_ignores = true
Run it:
mypy src/
# Success: no issues found in 42 source files
Gradual Adoption
You don’t have to type everything at once. Start with:
- New code — require type hints for all new modules
- Public APIs — type your function signatures first
- Critical paths — add types to business logic and data transformations
- Use
# type: ignoresparingly for legacy code you’ll fix later
Common Patterns
Result Type (No Exceptions)
from dataclasses import dataclass
from typing import Generic, TypeVar
T = TypeVar("T")
E = TypeVar("E")
@dataclass(frozen=True)
class Ok(Generic[T]):
value: T
@dataclass(frozen=True)
class Err(Generic[E]):
error: E
Result = Ok[T] | Err[E]
def divide(a: float, b: float) -> Result[float, str]:
if b == 0:
return Err("Division by zero")
return Ok(a / b)
match divide(10, 3):
case Ok(value):
print(f"Result: {value}")
case Err(error):
print(f"Error: {error}")
Key Takeaways
- Type hints are documentation that doesn’t go stale — they’re checked by tools.
- Start with function signatures — the highest impact for the least effort.
- Use Protocols over ABCs — structural typing is more Pythonic.
- TypedDict for JSON shapes — catches key typos at the type level.
- Gradual adoption works — you don’t need 100% coverage to get value.
- Run mypy in CI — make it fail the build on type errors.
Type hints don’t make Python into Java. They give you a safety net while keeping everything that makes Python great.