🎯 Learning Objectives
- Annotate variables, function parameters, and return types
- Use
typingmodule types:Optional,Union,List,Dict,Tuple,Callable,TypeVar - Use modern built-in generic syntax (Python 3.10+)
- Write generic functions and classes with
TypeVarandGeneric - Use
Protocolfor structural typing - Run
mypyand interpret its output - Apply type hints progressively without breaking existing code
Why Type Hints?
Python is dynamically typed — you never have to declare types. But as codebases grow, type hints become invaluable:
- Catch bugs early — a type checker finds mistakes before runtime
- Self-documenting — the signature tells you exactly what a function expects
- Better IDE support — autocompletion, refactoring, and inline docs improve dramatically
- Safer refactoring — changing a function's signature surfaces all call sites that break
# Without type hints — what does this accept and return?
def process(data, threshold):
return [x for x in data if x > threshold]
# With type hints — immediately clear
def process(data: list[float], threshold: float) -> list[float]:
return [x for x in data if x > threshold]
motivation.py
Basic Annotations
# ── Variable annotations ──
name: str = "Alice"
age: int = 30
score: float = 9.5
active: bool = True
# Without assignment (declares the type only)
total: int
# ── Function annotations ──
def greet(name: str, times: int = 1) -> str:
return (f"Hello, {name}! " * times).strip()
# ── Return None explicitly ──
def log(message: str) -> None:
print(message)
# ── Multiple return types ──
def divide(a: float, b: float) -> float | None: # Python 3.10+
if b == 0:
return None
return a / b
basic_annotations.py
Built-in Generic Types (Python 3.9+)
Since Python 3.9 you can use built-in types directly as generics —
no need to import from typing:
# Python 3.9+ — use built-in types directly
def process(items: list[int]) -> list[str]:
return [str(x) for x in items]
def lookup(table: dict[str, int], key: str) -> int | None:
return table.get(key)
def first_two(items: tuple[int, int]) -> int:
return items[0]
def get_unique(items: list[str]) -> set[str]:
return set(items)
# Nested generics
def group(data: list[dict[str, int]]) -> dict[str, list[int]]:
result: dict[str, list[int]] = {}
for row in data:
for k, v in row.items():
result.setdefault(k, []).append(v)
return result
builtin_generics.py
Python 3.8 and earlier — typing module
# For Python 3.8 compatibility, import from typing
from typing import List, Dict, Tuple, Set, Optional, Union
def process(items: List[int]) -> List[str]: ...
def lookup(table: Dict[str, int], key: str) -> Optional[int]: ...
# Optional[X] is shorthand for Union[X, None]
def find(name: str) -> Optional[str]: # can return str or None
...
# Union — accept multiple types
def stringify(value: Union[int, float, str]) -> str:
return str(value)
# Python 3.10+ shorthand: int | float | str
def stringify_modern(value: int | float | str) -> str:
return str(value)
typing_module.py
Special Types
from typing import Any, Literal, Final, ClassVar, TypeAlias
# Any — opt out of type checking (escape hatch)
def accept_anything(x: Any) -> Any:
return x
# Literal — only specific values allowed
def set_direction(d: Literal["north", "south", "east", "west"]) -> None: ...
# Final — constant that must not be reassigned
MAX_RETRIES: Final = 3
# ClassVar — class-level (not instance) attribute
from typing import ClassVar
class Counter:
count: ClassVar[int] = 0 # shared by all instances
def __init__(self) -> None:
Counter.count += 1
# TypeAlias (Python 3.10+) — named alias for complex types
Vector: TypeAlias = list[float]
Matrix: TypeAlias = list[Vector]
def dot(a: Vector, b: Vector) -> float:
return sum(x * y for x, y in zip(a, b))
special_types.py
Tuple annotations
from typing import Tuple # or just tuple[…] in 3.9+
# Fixed-length tuple: (int, str, float)
def parse_record(line: str) -> tuple[int, str, float]:
parts = line.split(",")
return int(parts[0]), parts[1], float(parts[2])
# Variable-length homogeneous tuple: any number of ints
def sum_tuple(t: tuple[int, ...]) -> int:
return sum(t)
# Empty tuple
def noop() -> tuple[()]:
return ()
tuple_types.py
Callable Types
from typing import Callable
# Callable[[arg_types], return_type]
def apply(func: Callable[[int], int], value: int) -> int:
return func(value)
apply(lambda x: x * 2, 5) # fine
apply(str.upper, 5) # mypy error: wrong signature
# A function that takes any args and returns None
Handler = Callable[..., None]
def register(handler: Handler) -> None:
...
# Callable with no arguments
Thunk = Callable[[], int]
def lazy(thunk: Thunk) -> int:
return thunk()
callable_type.py
TypeVar & Generic Functions
Use TypeVar to write functions that work with any type
while preserving the relationship between input and output types:
from typing import TypeVar
T = TypeVar("T")
# The return type is the SAME type as the input
def first(items: list[T]) -> T:
return items[0]
# mypy knows the return type from the argument
x: int = first([1, 2, 3]) # T = int
s: str = first(["a", "b"]) # T = str
# Constrained TypeVar — only int or float
Numeric = TypeVar("Numeric", int, float)
def double(x: Numeric) -> Numeric:
return x * 2 # type: ignore # * isn't defined for all T
# Bounded TypeVar — T must be a subclass of Comparable
from typing import Protocol
class Comparable(Protocol):
def __lt__(self, other: object) -> bool: ...
C = TypeVar("C", bound=Comparable)
def minimum(items: list[C]) -> C:
return min(items)
typevar.py
Generic classes
from typing import Generic, TypeVar
T = TypeVar("T")
class Stack(Generic[T]):
def __init__(self) -> None:
self._data: list[T] = []
def push(self, item: T) -> None:
self._data.append(item)
def pop(self) -> T:
if not self._data:
raise IndexError("pop from empty stack")
return self._data.pop()
def peek(self) -> T:
return self._data[-1]
def __len__(self) -> int:
return len(self._data)
s: Stack[int] = Stack()
s.push(1)
s.push(2)
x: int = s.pop() # mypy knows this is int
generic_class.py
Protocol — Structural Typing
Protocol lets you define an interface based on structure
(methods/attributes) rather than inheritance — Python's duck typing, made
type-checkable:
from typing import Protocol, runtime_checkable
@runtime_checkable
class Drawable(Protocol):
def draw(self) -> None: ...
def bounding_box(self) -> tuple[float, float, float, float]: ...
class Circle:
def draw(self) -> None:
print("Drawing circle")
def bounding_box(self) -> tuple[float, float, float, float]:
return (0.0, 0.0, 10.0, 10.0)
class Square:
def draw(self) -> None:
print("Drawing square")
def bounding_box(self) -> tuple[float, float, float, float]:
return (0.0, 0.0, 5.0, 5.0)
# Neither Circle nor Square inherits Drawable —
# but mypy accepts them wherever Drawable is expected
def render(shape: Drawable) -> None:
shape.draw()
print(f"Bounds: {shape.bounding_box()}")
render(Circle()) # ✓
render(Square()) # ✓
# @runtime_checkable enables isinstance checks
print(isinstance(Circle(), Drawable)) # True
protocol.py
TypedDict
TypedDict types a dictionary with specific string keys and value types —
great for API responses and structured records:
from typing import TypedDict, Required, NotRequired
class UserRecord(TypedDict):
id: int
name: str
email: str
age: NotRequired[int] # optional key (Python 3.11+)
def display_user(user: UserRecord) -> str:
return f"{user['name']} ({user['email']})"
# mypy checks key names and value types
alice: UserRecord = {"id": 1, "name": "Alice", "email": "alice@example.com"}
display_user(alice) # ✓
# mypy would flag:
# bad: UserRecord = {"id": "not-an-int", "name": "Bob", "email": "b@b.com"}
# ^^^^^^^^^^^^^ error: incompatible type
typeddict.py
Running mypy
# Install
pip install mypy
# Check a single file
mypy mymodule.py
# Check a whole package
mypy src/
# Strict mode — enable all checks
mypy --strict mymodule.py
# Common options
mypy --ignore-missing-imports mymodule.py # silence missing stub errors
mypy --disallow-untyped-defs mymodule.py # require all functions to be typed
mypy --show-error-codes mymodule.py # show error codes for suppression
mypy_commands.sh
mypy.ini / pyproject.toml configuration
# pyproject.toml
[tool.mypy]
python_version = "3.12"
strict = true
ignore_missing_imports = true
exclude = ["tests/", "docs/"]
# Per-module overrides — relax rules for legacy code
[[tool.mypy.overrides]]
module = "legacy.*"
ignore_errors = true
pyproject.toml
Suppressing errors
from typing import cast
# type: ignore — suppress a specific line
x: int = "not an int" # type: ignore[assignment]
# cast — tell mypy to treat a value as a specific type (no runtime effect)
value: object = get_value()
as_str: str = cast(str, value) # mypy trusts you; no check at runtime
# TYPE_CHECKING — imports only seen by the type checker, not at runtime
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from mymodule import HeavyClass # avoids circular import at runtime
suppression.py
Gradual Typing Strategy
- Start at the boundaries — annotate public API functions first (what callers see).
- Add types to new code — leave existing code alone initially.
- Use
--disallow-untyped-defsonce a module is fully annotated. - Enable
--strictmodule-by-module as confidence grows. - Run mypy in CI — prevent regressions from new untyped code.
from __future__ import annotations # defer annotation evaluation (Python 3.7–3.9)
# Allows using types that aren't defined yet, e.g. forward references
# and using 'list[int]' syntax on Python < 3.9
class Node:
def __init__(self, value: int, next: Node | None = None) -> None:
self.value = value
self.next = next # forward reference — fine with 'from __future__ import annotations'
forward_ref.py
Primary sources: Python Docs — typing module · mypy documentation · PEP 484 — Type Hints · PEP 604 — X | Y union syntax
Ask your AI tutor! Getting a confusing mypy error?
Not sure how to type a complex return type or a decorator?
Want to understand when to use Protocol vs ABC?
Type system questions are great to work through with examples.
💻 Exercises
Add complete, correct type annotations to all functions and variables
in the following module, then verify with mypy --strict:
def clamp(value, lo, hi):
return max(lo, min(hi, value))
def merge_dicts(base, overrides):
result = dict(base)
result.update(overrides)
return result
def chunk(lst, size):
return [lst[i:i+size] for i in range(0, len(lst), size)]
def find_first(items, predicate):
for item in items:
if predicate(item):
return item
return None
Show solution
from typing import TypeVar, Callable
T = TypeVar("T")
KT = TypeVar("KT")
VT = TypeVar("VT")
def clamp(value: float, lo: float, hi: float) -> float:
return max(lo, min(hi, value))
def merge_dicts(base: dict[KT, VT], overrides: dict[KT, VT]) -> dict[KT, VT]:
result = dict(base)
result.update(overrides)
return result
def chunk(lst: list[T], size: int) -> list[list[T]]:
return [lst[i:i+size] for i in range(0, len(lst), size)]
def find_first(items: list[T], predicate: Callable[[T], bool]) -> T | None:
for item in items:
if predicate(item):
return item
return None
# Tests — mypy should accept all of these
print(clamp(5.0, 0.0, 10.0))
print(merge_dicts({"a": 1}, {"b": 2}))
print(chunk([1, 2, 3, 4, 5], 2))
print(find_first([1, 2, 3, 4], lambda x: x > 2))
Implement a generic Result[T] type (inspired by Rust) that
wraps either a success value or an error, without raising exceptions:
Result.ok(value: T)— class method, success caseResult.err(error: str)— class method, failure caseis_ok— property,Trueif successunwrap() -> T— return value or raiseValueErrorunwrap_or(default: T) -> T— return value or defaultmap(func: Callable[[T], U]) -> Result[U]— transform value if ok
Show solution
from __future__ import annotations
from typing import TypeVar, Generic, Callable, Optional
T = TypeVar("T")
U = TypeVar("U")
class Result(Generic[T]):
def __init__(self, value: Optional[T], error: Optional[str]) -> None:
self._value = value
self._error = error
@classmethod
def ok(cls, value: T) -> Result[T]:
return cls(value, None)
@classmethod
def err(cls, error: str) -> Result[T]:
return cls(None, error)
@property
def is_ok(self) -> bool:
return self._error is None
def unwrap(self) -> T:
if self._error is not None:
raise ValueError(f"Called unwrap() on Err: {self._error}")
assert self._value is not None
return self._value
def unwrap_or(self, default: T) -> T:
return self._value if self.is_ok else default # type: ignore[return-value]
def map(self, func: Callable[[T], U]) -> Result[U]:
if not self.is_ok:
return Result.err(self._error or "unknown error")
return Result.ok(func(self.unwrap()))
def __repr__(self) -> str:
return f"Ok({self._value!r})" if self.is_ok else f"Err({self._error!r})"
def safe_divide(a: float, b: float) -> Result[float]:
if b == 0:
return Result.err("division by zero")
return Result.ok(a / b)
r1 = safe_divide(10, 2)
r2 = safe_divide(10, 0)
print(r1) # Ok(5.0)
print(r2) # Err('division by zero')
print(r1.unwrap()) # 5.0
print(r2.unwrap_or(0)) # 0
print(r1.map(lambda x: x * 2)) # Ok(10.0)
print(r2.map(lambda x: x * 2)) # Err('division by zero')
Design a plugin system using Protocol:
- Define a
Formatterprotocol withformat(data: dict[str, object]) -> str - Define a
Validatorprotocol withvalidate(data: dict[str, object]) -> list[str](returns a list of error messages) - Write a
Pipelineclass that accepts aValidatorand aFormatter, and has arun(data)method that validates, then formats if valid - Implement two concrete formatters (
JSONFormatterandTextFormatter) and one validator (RequiredFieldsValidator)
Show solution
from typing import Protocol
import json
class Formatter(Protocol):
def format(self, data: dict[str, object]) -> str: ...
class Validator(Protocol):
def validate(self, data: dict[str, object]) -> list[str]: ...
class JSONFormatter:
def format(self, data: dict[str, object]) -> str:
return json.dumps(data, indent=2)
class TextFormatter:
def format(self, data: dict[str, object]) -> str:
return "\n".join(f"{k}: {v}" for k, v in data.items())
class RequiredFieldsValidator:
def __init__(self, required: list[str]) -> None:
self.required = required
def validate(self, data: dict[str, object]) -> list[str]:
return [f"Missing required field: '{f}'" for f in self.required if f not in data]
class Pipeline:
def __init__(self, validator: Validator, formatter: Formatter) -> None:
self.validator = validator
self.formatter = formatter
def run(self, data: dict[str, object]) -> str | None:
errors = self.validator.validate(data)
if errors:
print("Validation errors:")
for e in errors:
print(f" - {e}")
return None
return self.formatter.format(data)
# Test
validator = RequiredFieldsValidator(["name", "email", "age"])
pipeline1 = Pipeline(validator, JSONFormatter())
pipeline2 = Pipeline(validator, TextFormatter())
good_data: dict[str, object] = {"name": "Alice", "email": "alice@example.com", "age": 30}
bad_data: dict[str, object] = {"name": "Bob"}
print(pipeline1.run(good_data))
print("---")
print(pipeline2.run(good_data))
print("---")
pipeline1.run(bad_data) # prints errors, returns None