Generics, Protocols and advanced typing

Python · Advanced · 15 min

Library authors and large codebases need precise types that express real relationships between values.

Generics connect types in a signature. With the 3.12+ syntax (PEP 695) type parameters go in brackets: def first[T](items: Sequence[T]) -> T: says the return type is the element type. Classes take them too: class Stack[T]:, and you can bound or constrain them: [T: Hashable], [N: (int, float)]. Python 3.13 adds defaults: class Box[T = int]:. The type statement declares aliases, even recursive ones: type JSON = dict[str, JSON] | list[JSON] | str | int | float | bool | None.

Protocols describe types structurally. A class satisfies class SupportsClose(Protocol): def close(self) -> None: ... just by having a matching close method — no inheritance needed. This is static duck typing and usually beats requiring an ABC. Add @runtime_checkable to allow isinstance checks (which only test method presence).

Other tools you'll meet:

  • TypedDict types dict-shaped data like JSON, with Required/NotRequired and 3.13's ReadOnly
  • Literal["r", "w"] restricts to specific values
  • @overload declares several signatures where the return type depends on arguments
  • Self for methods returning their own class; ParamSpec for typing decorators
  • TypeIs (3.13) and TypeGuard for user-defined narrowing functions
  • Final, ClassVar, @override (3.12), @warnings.deprecated (3.13)

Remember these are all for the checker. At runtime, annotations are still stored but not enforced; Python 3.14 makes their evaluation lazy by default, while in 3.13 from __future__ import annotations gives similar deferral.

Example

from collections.abc import Sequence
from typing import Protocol, TypedDict, Literal, TypeIs

def first[T](items: Sequence[T]) -> T:          # generic function (3.12+)
    return items[0]

class SupportsArea(Protocol):
    def area(self) -> float: ...
class Square:                                   # no inheritance needed
    def __init__(self, s: float) -> None: self.s = s
    def area(self) -> float: return self.s ** 2

def total_area(shapes: Sequence[SupportsArea]) -> float:
    return sum(s.area() for s in shapes)

class User(TypedDict):
    name: str
    role: Literal["admin", "member"]

def is_str_list(val: list[object]) -> TypeIs[list[str]]:   # 3.13
    return all(isinstance(x, str) for x in val)

type Pair[T] = tuple[T, T]                      # generic type alias
print(first("abc"), total_area([Square(2), Square(3)]), is_str_list(["a"]))  # a 13 True
print(User(name="ana", role="admin"), Pair[int])  # a plain dict; Pair[int]

Remember

  • def f[T](x: T) -> T and class C[T] declare generics (3.12+)
  • Protocols give structural typing without inheritance
  • TypedDict, Literal, overload and TypeIs express precise contracts

Watch out for

  • Using Any to silence the checker where a TypeVar or Protocol would express the real relationship.

Check yourself

  1. A class has a matching `read()` method but doesn't inherit from `Readable(Protocol)`. Does a type checker accept it where `Readable` is expected?

Sign in to answer, see the explanations, and get this lesson back as a quick review later.

Official docs: docs.python.org/3/library/typing.html#typing.Protocol