AdvancedPython · Lesson 2 of 9

Advanced Typing: Generics & Protocols

Generic functions and classes, Protocols for duck typing, and overloads.

Generics let a function or class work with any type while keeping type information: a Stack[int] only accepts ints, and first(items) returns the same type the list holds.

A Protocol describes the methods an object must have — any class with matching methods fits, without inheriting from anything. This is structural typing ("duck typing" that the type checker understands), and it's the cleanest way to define interfaces for things like repositories or notifiers.

@overload describes functions whose return type depends on the arguments. Python 3.12 adds shorter syntax (def first[T](items: list[T]) -> T), but the TypeVar form below works on every modern version.

typing_advanced.pyPython
from dataclasses import dataclass
from typing import Generic, Protocol, TypeVar, overload

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("pop from empty stack")
        return self._items.pop()


def first(items: list[T]) -> T | None:
    return items[0] if items else None


class Notifier(Protocol):
    def send(self, to: str, message: str) -> None: ...


class SmsNotifier:                      # no inheritance needed
    def send(self, to: str, message: str) -> None:
        print(f"SMS to {to}: {message}")


@dataclass
class ResultService:
    notifier: Notifier

    def publish(self, phone: str, average: float) -> None:
        self.notifier.send(phone, f"Your average is {average:.1f}")


@overload
def to_score(value: str) -> int: ...
@overload
def to_score(value: list[str]) -> list[int]: ...
def to_score(value: str | list[str]) -> int | list[int]:
    if isinstance(value, list):
        return [int(v) for v in value]
    return int(value)


stack: Stack[int] = Stack()
stack.push(1)
stack.push(2)
print(stack.pop(), first(["a", "b"]))

ResultService(SmsNotifier()).publish("0712345678", 78.456)
print(to_score("88") + 1, to_score(["1", "2"]))
Runs in your browser · Python

Key points

  • Generics keep types precise for containers and helpers.
  • Protocols define interfaces by shape — great for swapping implementations in tests.
  • @overload documents functions whose return type depends on input.

Exercise

Define a Repository[T] Protocol with get(id), add(item) and all(). Implement an InMemoryRepository for a Student dataclass and write a function that accepts any Repository[Student].

Show solution

Try the exercise yourself first — then compare your approach with this one.

A generic Protocol describes any repository by its methods. InMemoryRepository never mentions the protocol, yet mypy accepts it wherever a Repository[Student] is expected because its methods match. top_student works with any implementation — in-memory now, PostgreSQL later.

repository.pyPython
from dataclasses import dataclass
from typing import Generic, Protocol, TypeVar

T = TypeVar("T")


class Repository(Protocol[T]):
    def get(self, item_id: str) -> T | None: ...
    def add(self, item: T) -> None: ...
    def all(self) -> list[T]: ...


@dataclass
class Student:
    id: str
    name: str
    average: float


class InMemoryRepository(Generic[T]):
    def __init__(self, key: str = "id") -> None:
        self._items: dict[str, T] = {}
        self._key = key

    def get(self, item_id: str) -> T | None:
        return self._items.get(item_id)

    def add(self, item: T) -> None:
        self._items[getattr(item, self._key)] = item

    def all(self) -> list[T]:
        return list(self._items.values())


def top_student(repo: Repository[Student]) -> Student | None:
    return max(repo.all(), key=lambda s: s.average, default=None)


repo: InMemoryRepository[Student] = InMemoryRepository()
repo.add(Student("s1", "Amina", 86.0))
repo.add(Student("s2", "Juma", 54.0))

best = top_student(repo)
print(best.name if best else "nobody", repo.get("s2"))
Runs in your browser · Python

Check your understanding

  1. How does a class satisfy a Protocol?

  2. What does Stack[int] guarantee for a generic class Stack(Generic[T])?

  3. What is @overload used for?

  4. Why are Protocols handy for testing?

Ask AI