🟣 Advanced

Type Hints & mypy

📖 Lesson 30 ⏱ 40 min 🧪 5 questions 💻 3 exercises

🎯 Learning Objectives

  • Annotate variables, function parameters, and return types
  • Use typing module types: Optional, Union, List, Dict, Tuple, Callable, TypeVar
  • Use modern built-in generic syntax (Python 3.10+)
  • Write generic functions and classes with TypeVar and Generic
  • Use Protocol for structural typing
  • Run mypy and 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
Type hints are completely optional and have zero runtime cost by default — Python ignores them during execution. They are metadata for tools (mypy, pyright, IDEs) and humans. You can add them incrementally.

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-defs once a module is fully annotated.
  • Enable --strict module-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
🤖

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

01 Annotate a Module

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))
02 Generic Result Type

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 case
  • Result.err(error: str) — class method, failure case
  • is_ok — property, True if success
  • unwrap() -> T — return value or raise ValueError
  • unwrap_or(default: T) -> T — return value or default
  • map(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')
03 Protocol-Based Plugin System

Design a plugin system using Protocol:

  • Define a Formatter protocol with format(data: dict[str, object]) -> str
  • Define a Validator protocol with validate(data: dict[str, object]) -> list[str] (returns a list of error messages)
  • Write a Pipeline class that accepts a Validator and a Formatter, and has a run(data) method that validates, then formats if valid
  • Implement two concrete formatters (JSONFormatter and TextFormatter) 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