17. Typage Statique Avancé

TypeVar, Generic, Protocol, @overload, Self, TypeGuard, Final, Literal, TypedDict, Never, covariance/contravariance.


17.1 TypeVar — variables de type

from typing import TypeVar
 
T = TypeVar("T")           # n'importe quel type
U = TypeVar("U", int, str) # contraint : int ou str
V = TypeVar("V", bound=Number)  # doit être ou hériter de Number
def premier(seq: list[T]) -> T:
    return seq[0]
 
premier([1, 2, 3])     # int
premier(["a", "b"])    # str

Covariance, contravariance, invariance

T_co = TypeVar("T_co", covariant=True)     # list[Cat] est sous-type de list[Animal]
T_contra = TypeVar("T_contra", contravariant=True)
  • invariant (défaut) : list[Dog] n’est pas list[Animal]
  • covariant : producteur de T (ex: Iterator[T_co])
  • contravariant : consommateur de T (ex: Callable[[T_contra], None])

17.2 Generic — classes génériques

from typing import Generic, TypeVar
 
T = TypeVar("T")
 
class Pile(Generic[T]):
    def __init__(self) -> None:
        self._items: list[T] = []
 
    def push(self, item: T) -> None:
        self._items.append(item)
 
    def pop(self) -> T:
        return self._items.pop()
 
# Usage
pile_int = Pile[int]()
pile_str = Pile[str]()

Héritage de génériques

class PileOrdonnée(Pile[T]):
    def push(self, item: T) -> None:
        ...

Generic avec contraintes

from typing import Generic, TypeVar
 
Num = TypeVar("Num", int, float)
 
class Vecteur(Generic[Num]):
    def __init__(self, x: Num, y: Num):
        self.x = x
        self.y = y

17.3 Protocol — duck typing statique (PEP 544)

Un Protocol définit une interface sans héritage :

from typing import Protocol
 
class Volant(Protocol):
    def voler(self) -> None: ...
 
class Oiseau:
    def voler(self) -> None:
        print("L'oiseau vole")
 
class Avion:
    def voler(self) -> None:
        print("L'avion vole")
 
def faire_voler(obj: Volant) -> None:
    obj.voler()
 
faire_voler(Oiseau())  # OK
faire_voler(Avion())   # OK

Comparaison avec l’héritage formel :

# Approche traditionnelle (héritage)
class Volant(ABC):
    @abstractmethod
    def voler(self) -> None: ...
 
# Approche Protocol (duck typing)
class Volant(Protocol):
    def voler(self) -> None: ...

Protocol avec TypeVar

from typing import Protocol, TypeVar
 
T = TypeVar("T", bound="Comparable")
 
class Comparable(Protocol):
    def __lt__(self, other: object) -> bool: ...
 
def max_deux(a: T, b: T) -> T:
    return a if a > b else b

@runtime_checkable

Vérifiable à l’exécution avec isinstance :

from typing import Protocol, runtime_checkable
 
@runtime_checkable
class Iterable(Protocol):
    def __iter__(self): ...
 
isinstance([1, 2, 3], Iterable)  # True

17.4 @overload — signatures multiples

Déclarer plusieurs signatures pour une même fonction :

from typing import overload
 
@overload
def doublé(x: int) -> int: ...
 
@overload
def doublé(x: str) -> str: ...
 
@overload
def doublé(x: list[T]) -> list[T]: ...
 
def doublé(x: int | str | list[T]) -> int | str | list[T]:
    if isinstance(x, int):
        return x * 2
    if isinstance(x, str):
        return x * 2
    return [item * 2 for item in x]

Usage typique : numpy.ndarray.__getitem__ a des douzaines de @overload.

17.5 Self (3.11+)

Retourner le type de self dans une classe :

from typing import Self
 
class Builder:
    def set_nom(self, nom: str) -> Self:
        self.nom = nom
        return self
 
    def set_âge(self, âge: int) -> Self:
        self.âge = âge
        return self
 
class BuilderEnfant(Builder):
    def set_école(self, école: str) -> Self:
        self.école = école
        return self
 
# Le type retourné est BuilderEnfant, pas Builder
b = BuilderEnfant().set_nom("Alice").set_école("Lycée")

Avant 3.11, il fallait écrire :

from typing import TypeVar
 
T = TypeVar("T", bound="Builder")
 
class Builder:
    def set_nom(self: T, nom: str) -> T:
        ...

17.6 TypeGuard — narrowing par fonction (3.10+)

from typing import TypeGuard
 
def est_str_liste(val: list[object]) -> TypeGuard[list[str]]:
    return all(isinstance(x, str) for x in val)
 
def traiter(val: list[object]) -> None:
    if est_str_liste(val):
        # ici mypy sait que val est list[str]
        print(" ".join(val))

17.7 Final — constantes et méthodes non surchargeables

from typing import Final
 
PI: Final = 3.14159
# PI = 3  # mypy: error
 
class Base:
    def méthode_finale(self) -> None: ...

17.8 Literal — valeurs littérales exactes

from typing import Literal
 
def set_mode(mode: Literal["train", "eval", "predict"]) -> None:
    ...
 
set_mode("train")    # OK
set_mode("unknown")  # mypy: error
 
def open_file(mode: Literal["r", "w", "a", "rb"]) -> None: ...
 
type Mode = Literal["train", "eval"]
def basculer(mode: Mode) -> None: ...

Literal vs Enum

from typing import Literal
from enum import Enum
 
# Approche Literal — typage statique uniquement
ModeStr = Literal["train", "eval", "predict"]
def basculer(mode: ModeStr) -> None: ...
basculer("train")     # OK
basculer("unknown")   # mypy/pyright: error
 
# Approche Enum — existe à l'exécution
class Mode(Enum):
    TRAIN = "train"
    EVAL = "eval"
    PREDICT = "predict"
 
def basculer(mode: Mode) -> None: ...
basculer(Mode.TRAIN)  # OK
basculer("train")     # mypy: error (attendu Mode, pas str)

Quand utiliser quoi ?

CritèreLiteralEnum
Existence runtimeDisparaît à la compilationClasse réelle, isinstance(m, Mode)
Valeurs autoriséesChaînes/nombres/booleans uniquementN’importe quoi (StrEnum, IntEnum, etc.)
Autocomplétion IDELimitée (suggestions de base)Excellente (Mode.TRAIN suggéré)
SérialisationDirecte ("train" s’écrit tel quel)Besoin de .value ou d’un helper
Pattern matchingmatch x: case "train":match x: case Mode.TRAIN: (3.10+)
ItérationImpossiblelist(Mode) fonctionne
Ajout d’attributsImpossibleMéthodes, @property, auto()
AliasingPossible via type Mode = Literal[...]Mode.TRAIN is Mode.TRAIN (singleton)

Recommandation :

  • Enum : valeur connue et réutilisée dans tout le codebase, besoin de méthodes, itération, validation runtime, sérialisation personnalisée
  • Literal : valeur passagère, paramètre de fonction rare, compatibilité avec des APIs externes qui attendent des str, typage rapide sans créer de classe
# Bon cas pour Literal : paramètre rare
def set_temp(temp: float, unité: Literal["C", "F", "K"]) -> None: ...
 
# Bon cas pour Enum : concept central du domaine
class Status(IntEnum):
    OK = 200
    NOT_FOUND = 404
    ERROR = 500

17.9 Never — fonction qui ne retourne jamais

from typing import Never
 
def erreur(msg: str) -> Never:
    raise RuntimeError(msg)
 
def boucle_infinie() -> Never:
    while True:
        pass

17.10 Annotated — métadonnées sur les types (3.11+)

from typing import Annotated
 
def set_temp(temp: Annotated[float, "Celsius"]) -> None: ...
def set_age(age: Annotated[int, GTE(0), LTE(150)]) -> None: ...

17.11 TypeAlias (3.10+)

from typing import TypeAlias
 
Vector: TypeAlias = list[float]
Matrice: TypeAlias = list[list[float]]
 
def produit_scalaire(a: Vector, b: Vector) -> float: ...

17.12 Unpack — dépaqueter des *args typés

from typing import Unpack
 
class Position(TypedDict):
    x: int
    y: int
 
def déplacer(**kwargs: Unpack[Position]) -> None: ...
 
déplacer(x=10, y=20)

17.13 Configuration mypy

# mypy.ini
[mypy]
python_version = 3.13
strict = True
warn_unused_configs = True
disallow_untyped_defs = True
disallow_any_unimported = True
mypy mon_script.py          # Vérification
mypy --strict mon_script.py # Mode strict

17.14 Bonnes pratiques

  • Utiliser TypeVar avec bound plutôt que Any
  • Toujours annoter les fonctions publiques et les signatures
  • Protocol pour les interfaces implicites (duck typing typé)
  • @overload pour les fonctions à comportement différent selon le type
  • Final pour les vraies constantes
  • Utiliser Self pour les builders et copy()/replace()
  • Éviter Any autant que possible

🔗 ← Retour au cours · ← précédent · Suivant →