Why Type Hints#
# Without types — what does this accept? What does it return?
def process(data, config):
...
# With types — self-documenting, IDE-supported, statically checkable
def process(data: list[dict[str, Any]], config: ProcessConfig) -> ProcessResult:
...Benefits:
- IDE autocompletion and error detection
- Static analysis catches bugs before runtime
- Self-documenting code
- Refactoring confidence
Important: Type hints are NOT enforced at runtime by default. They’re for tools (mypy, pyright, IDEs).
Basic Type Annotations#
from typing import Any
# Variables
name: str = "Alice"
age: int = 30
active: bool = True
score: float = 95.5
# Functions
def greet(name: str, excited: bool = False) -> str:
if excited:
return f"Hello, {name}!!!"
return f"Hello, {name}"
# None return
def log(message: str) -> None:
print(message)
# Collections (Python 3.9+ — use built-in types directly)
numbers: list[int] = [1, 2, 3]
mapping: dict[str, int] = {"a": 1, "b": 2}
coordinates: tuple[float, float] = (3.14, 2.71)
unique: set[str] = {"apple", "banana"}
# Variable-length tuple
values: tuple[int, ...] = (1, 2, 3, 4, 5)Union Types and Optional#
# Union — accepts multiple types
def parse_id(value: int | str) -> int: # Python 3.10+ syntax
if isinstance(value, str):
return int(value)
return value
# Optional — shorthand for X | None
def find_user(user_id: int) -> dict | None:
"""Returns user dict or None if not found."""
...
# Pre-3.10 syntax (still valid)
from typing import Union, Optional
def parse_id(value: Union[int, str]) -> int: ...
def find_user(user_id: int) -> Optional[dict]: ...Generics#
from typing import TypeVar, Generic
T = TypeVar("T")
K = TypeVar("K")
V = TypeVar("V")
# Generic function
def first(items: list[T]) -> T | None:
return items[0] if items else None
# The return type matches the input type
first([1, 2, 3]) # inferred as int | None
first(["a", "b"]) # inferred as str | None
# Bounded TypeVar — restricts to specific types
from typing import TypeVar
Numeric = TypeVar("Numeric", int, float)
def add(a: Numeric, b: Numeric) -> Numeric:
return a + b
# Generic class
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:
if not self._items:
raise IndexError("Stack is empty")
return self._items[-1]
def __len__(self) -> int:
return len(self._items)
# Usage — type checker knows the element type
int_stack: Stack[int] = Stack()
int_stack.push(42)
value: int = int_stack.pop() # Type checker knows this is intProtocols — Structural Typing (Duck Typing with Types)#
from typing import Protocol, runtime_checkable
# Protocol defines what methods/attributes an object must have
# WITHOUT requiring inheritance
@runtime_checkable
class Renderable(Protocol):
def render(self) -> str: ...
@runtime_checkable
class Sized(Protocol):
def __len__(self) -> int: ...
class HTMLWidget:
"""No inheritance from Renderable — just implements the method."""
def render(self) -> str:
return "<div>Widget</div>"
class JSONResponse:
def render(self) -> str:
return '{"status": "ok"}'
# Both satisfy Renderable without inheriting from it
def display(item: Renderable) -> None:
print(item.render())
display(HTMLWidget()) # OK
display(JSONResponse()) # OK
# Runtime check (because @runtime_checkable)
isinstance(HTMLWidget(), Renderable) # True
# Complex protocol
class Repository(Protocol[T]):
def get(self, id: str) -> T | None: ...
def save(self, entity: T) -> None: ...
def delete(self, id: str) -> bool: ...
def list(self, limit: int = 100) -> list[T]: ...TypedDict — Typed Dictionaries#
from typing import TypedDict, Required, NotRequired
class UserProfile(TypedDict):
name: str
email: str
age: int
bio: NotRequired[str] # Optional key
class APIResponse(TypedDict):
status: int
data: dict[str, Any]
errors: NotRequired[list[str]]
def create_user(profile: UserProfile) -> APIResponse:
# Type checker knows exactly which keys exist and their types
print(profile["name"]) # OK — str
# print(profile["phone"]) # Error — key doesn't exist
return {"status": 201, "data": {"id": "123", **profile}}
# TypedDict with inheritance
class BaseEvent(TypedDict):
event_type: str
timestamp: str
class ClickEvent(BaseEvent):
x: int
y: int
element_id: strCallable Types#
from typing import Callable, ParamSpec, Concatenate
from collections.abc import Callable as CallableABC
# Function that accepts a callback
def apply_operation(
values: list[int],
operation: Callable[[int], int] # Takes int, returns int
) -> list[int]:
return [operation(v) for v in values]
apply_operation([1, 2, 3], lambda x: x * 2) # [2, 4, 6]
# ParamSpec — preserve parameter types through decorators
P = ParamSpec("P")
R = TypeVar("R")
def logged(func: Callable[P, R]) -> Callable[P, R]:
"""Decorator that preserves the exact signature of the wrapped function."""
@wraps(func)
def wrapper(*args: P.args, **kwargs: P.kwargs) -> R:
print(f"Calling {func.__name__}")
return func(*args, **kwargs)
return wrapper
@logged
def add(a: int, b: int) -> int:
return a + b
# Type checker knows add still takes (int, int) -> intLiteral, Final, and TypeGuard#
from typing import Literal, Final, TypeGuard
# Literal — restrict to specific values
def set_direction(direction: Literal["north", "south", "east", "west"]) -> None:
...
set_direction("north") # OK
set_direction("up") # Type error!
# Final — cannot be reassigned or overridden
MAX_RETRIES: Final = 3
MAX_RETRIES = 5 # Type error!
class Base:
@final
def critical_method(self) -> None:
"""Subclasses cannot override this."""
...
# TypeGuard — narrow types in conditional checks
def is_string_list(val: list[Any]) -> TypeGuard[list[str]]:
"""Returns True if all elements are strings."""
return all(isinstance(x, str) for x in val)
def process(data: list[Any]) -> None:
if is_string_list(data):
# Type checker now knows data is list[str]
for s in data:
print(s.upper()) # OK — s is strType Aliases and NewType#
from typing import NewType, TypeAlias
# Type alias — just a shorthand (no runtime distinction)
JSON: TypeAlias = dict[str, "JSON"] | list["JSON"] | str | int | float | bool | None
Headers: TypeAlias = dict[str, str]
def fetch(url: str, headers: Headers) -> JSON:
...
# NewType — creates a distinct type (catches mixing up same-typed values)
UserId = NewType("UserId", int)
OrderId = NewType("OrderId", int)
def get_user(user_id: UserId) -> dict:
...
def get_order(order_id: OrderId) -> dict:
...
uid = UserId(42)
oid = OrderId(42)
get_user(uid) # OK
get_user(oid) # Type error! OrderId is not UserId
get_user(42) # Type error! int is not UserIdOverload — Multiple Signatures#
from typing import overload
@overload
def fetch(url: str, raw: Literal[True]) -> bytes: ...
@overload
def fetch(url: str, raw: Literal[False] = ...) -> str: ...
def fetch(url: str, raw: bool = False) -> bytes | str:
response = requests.get(url)
if raw:
return response.content
return response.text
# Type checker knows:
result1 = fetch("http://example.com", raw=True) # bytes
result2 = fetch("http://example.com", raw=False) # str
result3 = fetch("http://example.com") # strRunning Static Analysis#
mypy Configuration#
# 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
# Per-module overrides
[[tool.mypy.overrides]]
module = "tests.*"
disallow_untyped_defs = false
[[tool.mypy.overrides]]
module = "third_party_lib.*"
ignore_missing_imports = true# Run mypy
mypy src/
mypy src/ --strict
mypy src/module.py --show-error-codesCommon mypy Errors and Fixes#
# Error: Incompatible return value type (got "Optional[str]", expected "str")
def get_name(user: dict) -> str:
return user.get("name") # .get() returns Optional[str]
# Fix: handle the None case
def get_name(user: dict) -> str:
name = user.get("name")
if name is None:
raise ValueError("User has no name")
return name
# Error: Item "None" of "Optional[User]" has no attribute "email"
user = find_user(123)
print(user.email) # user might be None!
# Fix: narrow the type
user = find_user(123)
if user is not None:
print(user.email) # OK — type narrowed to User
# Or use assert (for cases where None is a bug)
user = find_user(123)
assert user is not None, f"User 123 must exist"
print(user.email)Hypothetical Use Case: Typed Event System#
from typing import TypeVar, Generic, Callable, Protocol
from dataclasses import dataclass
E = TypeVar("E", bound="Event")
@dataclass(frozen=True)
class Event:
"""Base event class."""
pass
@dataclass(frozen=True)
class UserCreated(Event):
user_id: str
email: str
@dataclass(frozen=True)
class OrderPlaced(Event):
order_id: str
user_id: str
total: float
class EventHandler(Protocol[E]):
def __call__(self, event: E) -> None: ...
class EventBus:
def __init__(self) -> None:
self._handlers: dict[type[Event], list[Callable]] = {}
def subscribe(self, event_type: type[E], handler: EventHandler[E]) -> None:
self._handlers.setdefault(event_type, []).append(handler)
def publish(self, event: Event) -> None:
for handler in self._handlers.get(type(event), []):
handler(event)
# Usage — fully type-safe
bus = EventBus()
def on_user_created(event: UserCreated) -> None:
send_welcome_email(event.email)
def on_order_placed(event: OrderPlaced) -> None:
process_payment(event.order_id, event.total)
bus.subscribe(UserCreated, on_user_created)
bus.subscribe(OrderPlaced, on_order_placed)
bus.publish(UserCreated(user_id="u1", email="alice@example.com"))Key Takeaways#
- Type hints don’t run — they’re for tools (mypy, pyright, IDEs)
- Use
X | Noneinstead ofOptional[X](Python 3.10+) - Protocols enable duck typing with type safety — no inheritance required
- Generics make reusable, type-safe containers and functions
- NewType prevents mixing up same-typed values (UserId vs OrderId)
- TypedDict types dictionary shapes — essential for JSON/API data
- ParamSpec preserves function signatures through decorators
- Start with
mypy --stricton new projects — retrofitting is harder