backend / python
Typing avancé : Generic, Protocol, overload
Explication
Ce que vous allez apprendre
- Comprendre que les annotations de type Python ne sont jamais vérifiées à l'exécution
- Utiliser
Generic/TypeVarpour écrire une classe ou fonction paramétrée par un type - Utiliser
Protocolpour 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.
| Outil | Vérifie quoi | Basé sur |
|---|---|---|
Generic/TypeVar | Cohérence d'un type paramétré | Filiation explicite |
Protocol | Présence des bonnes méthodes | Structure de l'objet (duck typing) |
TypedDict | Clés et types d'un dictionnaire | Forme du dictionnaire |
overload | Signatures multiples valides | Documentation 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).
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 wrapperRésumé
- Le typing Python est statique et optionnel : ignoré à l'exécution, vérifié par
mypy/pyright. Generic/TypeVarparamétrisent une classe ou fonction par un type (ou la syntaxe[T]en 3.12+).Protocolpermet le duck typing structurel : pas besoin d'héritage explicite.overloaddocumente plusieurs signatures pour une fonction n'ayant qu'une seule implémentation réelle.
Exercices pratiques
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.