Retour au cours

backend / python

Typing avancé : Generic, Protocol, overload

Leçon 171 exercice

Explication

Ce que vous allez apprendre

  • Comprendre que les annotations de type Python ne sont jamais vérifiées à l'exécution
  • Utiliser Generic/TypeVar pour écrire une classe ou fonction paramétrée par un type
  • Utiliser Protocol pour typer selon la forme d'un objet plutôt que selon son héritage
  • Documenter plusieurs signatures d'une même fonction avec @overload
  • Typer précisément un dictionnaire JSON avec TypedDict

Dans quel contexte ?

Un développeur rejoint une équipe sur un projet FastAPI de plusieurs dizaines de milliers de lignes et voit une pull request refusée par la CI avant même d'avoir lancé un seul test, à cause d'une erreur mypy : Argument 1 to "calculer_total" has incompatible type "str"; expected "int". Sans typage, ce genre d'erreur ne serait détecté qu'en production, au moment où un utilisateur enverrait effectivement une chaîne au lieu d'un nombre. Cette leçon explique comment ces vérifications statiques fonctionnent et pourquoi elles n'ont aucun coût à l'exécution.

Des annotations qui ne changent rien à l'exécution

Une confusion fréquente : les annotations de type en Python (def f(x: int) -> str) ne sont vérifiées par rien au moment où le programme s'exécute. Elles servent uniquement d'indication pour des outils externes comme mypy ou pyright, qui analysent le code statiquement avant même de le lancer, un peu comme un correcteur orthographique relit un texte sans jamais l'exécuter. C'est ce qui distingue Python d'un langage à typage statique classique : le typage reste entièrement optionnel et n'a aucun coût à l'exécution.

Prérequis

Cette leçon suppose que vous êtes déjà à l'aise avec les fonctions, les classes et les generics au sens large. Si TypeVar ou Generic vous semblent abstraits, relisez d'abord la leçon sur les fonctions avant de continuer.

Pourquoi s'embêter à typer, alors

Sur un petit script, l'intérêt est limité. Mais sur un projet de plusieurs milliers de lignes maintenu par une équipe, les types documentent les intentions, permettent à l'éditeur de code de proposer une autocomplétion fiable, et détectent des erreurs avant même d'exécuter le moindre test.

Generic et Protocol : deux philosophies différentes du typage

Generic/TypeVar permettent de définir une classe ou fonction paramétrée par un type, un peu comme les génériques de Java ou C#. Protocol, à l'inverse, incarne le duck typing structurel typiquement pythonique : un objet est accepté s'il possède les bonnes méthodes, sans avoir besoin d'hériter explicitement d'une interface. C'est un typage basé sur la forme, pas sur la filiation.

OutilVérifie quoiBasé sur
Generic/TypeVarCohérence d'un type paramétréFiliation explicite
ProtocolPrésence des bonnes méthodesStructure de l'objet (duck typing)
TypedDictClés et types d'un dictionnaireForme du dictionnaire
overloadSignatures multiples validesDocumentation pour le type checker

Des outils pour des cas précis

overload documente plusieurs signatures pour une fonction qui n'a qu'une seule implémentation réelle. TypedDict type finement un dictionnaire dont on connaît les clés à l'avance, très utile pour représenter des réponses JSON structurées sans créer une classe complète.

Commandes & code

Typing avancé

Exploiter le système de types statiques de Python (vérifié par mypy/pyright, pas à l'exécution).

python
from typing import (
    TypeVar, Generic, Protocol, overload, Literal, Final,
    TypeAlias, TypedDict, ParamSpec, Callable, runtime_checkable,
)

# Types de base et types composes modernes (Python 3.9+ : sans importer List/Dict)
def traiter(noms: list[str], scores: dict[str, int]) -> tuple[str, int]:
    meilleur = max(scores.items(), key=lambda item: item[1])
    return meilleur

# Union moderne avec | (Python 3.10+)
def afficher(valeur: int | str | None) -> str:
    return str(valeur) if valeur is not None else "N/A"

# --- Generics : classes/fonctions parametrees par un type ---
T = TypeVar("T")

class Pile(Generic[T]):
    def __init__(self) -> None:
        self._items: list[T] = []

    def empiler(self, item: T) -> None:
        self._items.append(item)

    def depiler(self) -> T:
        return self._items.pop()

    def est_vide(self) -> bool:
        return len(self._items) == 0

pile_entiers: Pile[int] = Pile()
pile_entiers.empiler(1)
pile_entiers.empiler(2)
# pile_entiers.empiler("texte")    # erreur detectee par un type checker (pas a l'execution)

# Syntaxe generique moderne (Python 3.12+)
class Boite[T]:
    def __init__(self, contenu: T) -> None:
        self.contenu = contenu

def premier[T](items: list[T]) -> T:
    return items[0]

# TypeVar borne : restreint les types acceptes
NumericT = TypeVar("NumericT", int, float)

def doubler(valeur: NumericT) -> NumericT:
    return valeur * 2

# --- Protocol : duck typing structurel (pas besoin d'heriter explicitement) ---
@runtime_checkable
class Dessinable(Protocol):
    def dessiner(self) -> str: ...

class Cercle:
    def dessiner(self) -> str:
        return "O"

class Carre:
    def dessiner(self) -> str:
        return "[]"

def afficher_forme(forme: Dessinable) -> None:
    # Cercle et Carre ne "heritent" pas de Dessinable, mais respectent sa structure
    print(forme.dessiner())

afficher_forme(Cercle())
afficher_forme(Carre())
print(isinstance(Cercle(), Dessinable))    # True grace a @runtime_checkable

# --- overload : plusieurs signatures pour une meme fonction ---
@overload
def traiter_entree(valeur: int) -> str: ...
@overload
def traiter_entree(valeur: str) -> int: ...

def traiter_entree(valeur):
    # une SEULE implementation reelle ; @overload sert uniquement le type checker
    if isinstance(valeur, int):
        return str(valeur)
    return len(valeur)

# --- Literal, Final, TypedDict ---
Mode = Literal["lecture", "ecriture", "ajout"]

def ouvrir_mode(chemin: str, mode: Mode) -> None:
    ...

VERSION: Final = "1.0.0"     # signale au type checker que la valeur ne doit pas changer

class Utilisateur(TypedDict):
    nom: str
    age: int
    email: str | None

def creer_utilisateur() -> Utilisateur:
    return {"nom": "Alice", "age": 30, "email": None}

# TypeAlias : nommer un type complexe pour la lisibilite
JSON: TypeAlias = "dict[str, JSON] | list[JSON] | str | int | float | bool | None"

# ParamSpec : preserver la signature exacte d'une fonction a travers un decorateur type
P = ParamSpec("P")
def journaliser[**P, R](fonction: Callable[P, R]) -> Callable[P, R]:
    def wrapper(*args: P.args, **kwargs: P.kwargs) -> R:
        print(f"Appel de {fonction.__name__}")
        return fonction(*args, **kwargs)
    return wrapper

Résumé

  • Le typing Python est statique et optionnel : ignoré à l'exécution, vérifié par mypy/pyright.
  • Generic/TypeVar paramétrisent une classe ou fonction par un type (ou la syntaxe [T] en 3.12+).
  • Protocol permet le duck typing structurel : pas besoin d'héritage explicite.
  • overload documente plusieurs signatures pour une fonction n'ayant qu'une seule implémentation réelle.

Exercices pratiques

1 disponible
1

Mission : faire passer la CI d'une pull request bloquée par mypy

Objectif : Comprendre la nature purement statique du typage Python, corriger une erreur mypy, puis concevoir une interface avec Protocol plutôt qu'un héritage rigide.

Contexte

Une pull request est bloquée en CI par mypy avec l'erreur Argument 1 to "calculer_total" has incompatible type "str"; expected "int", alors que le code s'exécute parfaitement en local sans lever aucune erreur, y compris avec des valeurs de mauvais type passées manuellement.

Tu dois expliquer ce paradoxe apparent, corriger l'appel fautif, puis concevoir une fonction générique qui accepte n'importe quel objet "dessinable" sans imposer d'héritage.

Résoudre l’exercice →