Why Type Hints?
Python is dynamically typed — you don't declare variable types. This is great for quick prototyping, but as codebases grow, it becomes hard to know what type a function expects or returns. Type hints (PEP 484, Python 3.5+) let you annotate your code with expected types without changing how Python runs it.
Type hints are optional but powerful
Python ignores type hints at runtime. They're documentation that tools like mypy, IDEs (VS Code, PyCharm), and linters use to catch bugs before you run your code. Think of them as a spell-checker for types.
Basic Annotations
def greet(name: str, times: int = 1) -> str:
"""Greet someone. Type hints on params and return."""
return (f"Hello, {name}! " * times).strip()
# Variables can be annotated too
age: int = 30
scores: list[int] = [95, 87, 92]
config: dict[str, str] = {"host": "localhost", "port": "8080"}
# Python 3.10+ allows X | Y instead of Union
def process(value: str | int) -> str:
return str(value)
result = greet("Alice", 2)
print(result)
Hello, Alice! Hello, Alice!
The typing Module
For more complex types, use the typing module. Python 3.9+ lets you use built-in types directly (list[int]), but typing is needed for older versions and advanced patterns:
from typing import Optional, Union, Callable, Any
# Optional — can be the type or None
def find_user(user_id: int) -> Optional[dict]:
"""Returns user dict or None if not found."""
users = {1: {"name": "Alice"}, 2: {"name": "Bob"}}
return users.get(user_id)
# Callable — a function type
def apply(func: Callable[[int, int], int], a: int, b: int) -> int:
"""Apply a function to two arguments."""
return func(a, b)
result = apply(lambda x, y: x + y, 3, 4)
print(result) # 7
# Union (pre-3.10 style)
def format_value(value: Union[str, int, float]) -> str:
return f"Value: {value}"
7
Generics and TypeVar
Generics let you write functions and classes that work with any type while maintaining type safety. TypeVar creates a type variable that represents "some type":
from typing import TypeVar, Generic
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 __len__(self) -> int:
return len(self._items)
# Type-safe: mypy knows this is a Stack of ints
int_stack: Stack[int] = Stack()
int_stack.push(1)
int_stack.push(2)
print(int_stack.pop()) # 2
# And this is a Stack of strings
str_stack: Stack[str] = Stack()
str_stack.push("hello")
print(str_stack.pop()) # hello
2 hello
Protocol — Structural Subtyping
Protocol (Python 3.8+) defines an interface without requiring inheritance. If a class has the right methods, it satisfies the protocol — this is called structural subtyping or "duck typing with type checking":
from typing import Protocol
class Drawable(Protocol):
def draw(self) -> str: ...
class Circle:
def draw(self) -> str:
return "Drawing circle"
class Square:
def draw(self) -> str:
return "Drawing square"
def render(shape: Drawable) -> None:
"""Works with ANY object that has a draw() method."""
print(shape.draw())
render(Circle()) # Works — Circle has draw()
render(Square()) # Works — Square has draw()
# render(42) # mypy error: int doesn't have draw()
Drawing circle Drawing square
Running mypy
# Install
pip install mypy
# Check a file
mypy your_script.py
# Check entire project with strict mode
mypy --strict src/
# Configuration in pyproject.toml
# [tool.mypy]
# python_version = "3.12"
# strict = true
# ignore_missing_imports = true
| Type | Syntax | Example |
|---|---|---|
| Basic | int, str, float, bool | x: int = 42 |
| List | list[T] | nums: list[int] = [1, 2] |
| Dict | dict[K, V] | d: dict[str, int] |
| Optional | T | None | name: str | None = None |
| Union | T | U | val: int | str |
| Callable | Callable[[args], ret] | f: Callable[[int], str] |
| Any | Any | Disables type checking for this value |
| Tuple | tuple[T, ...] | point: tuple[int, int] |
🔍 Deep Dive: TypedDict and Literal
TypedDict lets you type dictionaries with specific key-value types: class User(TypedDict): name: str; age: int. Literal restricts to specific values: mode: Literal["read", "write"]. These are especially useful for typing JSON-like data and configuration options.
⚠️ Common Mistake: Annotating Everything as Any
Wrong:
from typing import Any
def process(data: Any) -> Any: # Defeats the purpose!
return data
Why: Any disables type checking entirely. It's the escape hatch, not the default. Using it everywhere is like turning off spell-check — technically allowed but you lose all the benefits.
Instead: Use specific types, generics, or Protocol to keep type safety.