Annotations de types et mypy#

Hide code cell source

import matplotlib.pyplot as plt
import matplotlib.patches as patches
import numpy as np
import seaborn as sns

sns.set_theme(style="whitegrid", palette="muted", font_scale=1.1)

Pourquoi annoter les types ?#

Python est un langage à typage dynamique : le type d’une variable est déterminé à l’exécution, et rien n’empêche de réassigner une variable à un objet d’un type différent. Cette flexibilité est une force, mais elle a un coût : les erreurs de type ne sont détectées qu’à l’exécution, parfois longtemps après l’introduction du bug, parfois dans des chemins de code rarement empruntés.

Les annotations de types (type hints), introduites par la PEP 484 (Python 3.5), sont la réponse de Python à ce problème. Elles permettent d”annoter le type attendu des variables, des paramètres et des valeurs de retour, sans changer le comportement dynamique du langage. Python n’évalue pas ces annotations à l’exécution : elles sont destinées aux outils d’analyse statique (notamment mypy, pyright, pylance) et aux IDE.

Les bénéfices sont multiples et concrets :

  1. Documentation vivante. Une signature def calculer_tva(prix: float, taux: float) -> float communique instantanément les types attendus et le type retourné, sans nécessiter un docstring.

  2. Détection précoce des erreurs. Un outil comme mypy peut signaler qu’on passe une chaîne là où un entier est attendu, avant même d’exécuter le code. Ces erreurs auraient pu passer des semaines inaperçues.

  3. Autocomplétion améliorée. Les IDE peuvent proposer des méthodes et attributs pertinents car ils connaissent le type exact de chaque variable.

  4. Refactorisation sûre. Changer le type d’une valeur retournée par une fonction déclenche des erreurs statiques dans tous les endroits qui en dépendent, rendant la refactorisation bien plus sûre.

  5. Évolution du code. Sur les grandes bases de code et en équipe, les annotations de types servent de contrat entre les composants, réduisant les malentendus sur les interfaces.

Il est important de comprendre que les annotations sont optionnelles et progressives : on peut annoter uniquement les fonctions publiques les plus critiques d’un projet, et ajouter des annotations ailleurs au fur et à mesure. Le code non annoté fonctionne exactement comme avant.

Syntaxe des annotations#

Variables#

# Annotation de variable (depuis Python 3.6)
nom: str = "Alice"
age: int = 30
prix: float = 19.99
actif: bool = True

# Annotation sans valeur initiale (déclare le type sans assigner)
identifiant: int

Paramètres et valeurs de retour#

La syntaxe pour annoter une fonction utilise : pour les paramètres et -> pour la valeur de retour :

def saluer(prenom: str, titre: str = "M.") -> str:
    return f"Bonjour, {titre} {prenom} !"

def somme(valeurs: list[int]) -> int:
    return sum(valeurs)

# Fonction sans valeur de retour : annoter avec None
def afficher(message: str) -> None:
    print(message)

print(saluer("Dupont"))
print(somme([1, 2, 3, 4, 5]))
Bonjour, M. Dupont !
15

Union de types : X | Y vs Union[X, Y]#

Avant Python 3.10, exprimer qu’une valeur peut être de l’un ou l’autre type nécessitait Union du module typing. Depuis Python 3.10, la syntaxe X | Y est disponible directement :

from typing import Union

# Avant Python 3.10
def ancienne_syntaxe(x: Union[int, str]) -> Union[int, str]:
    return x

# Depuis Python 3.10 (PEP 604)
def nouvelle_syntaxe(x: int | str) -> int | str:
    return x

# Type optionnel : None possible (équivalent à X | None)
from typing import Optional

def trouver(cle: str) -> Optional[str]:  # str | None depuis Python 3.10
    return None

print(nouvelle_syntaxe(42))
print(nouvelle_syntaxe("bonjour"))
42
bonjour

Le module typing#

Le module typing est la bibliothèque centrale pour les annotations de types complexes. Bien que Python 3.9+ permette d’utiliser directement list[int], dict[str, int], etc. (sans majuscules), le module typing reste utile pour les constructions avancées.

from typing import (
    Optional, Union, List, Dict, Tuple, Set,
    Any, Callable, TypeVar, Sequence, Iterable
)

# List, Dict, Tuple (deprecated depuis 3.9, mais encore courants)
def traiter(elements: List[int]) -> Dict[str, int]:
    return {"total": sum(elements), "max": max(elements)}

# Tuple avec types fixes
def coordonnees() -> Tuple[float, float]:
    return (3.14, 2.71)

# Callable : type d'une fonction
def appliquer(f: Callable[[int, int], int], a: int, b: int) -> int:
    return f(a, b)

print(appliquer(lambda x, y: x + y, 3, 4))
print(traiter([1, 2, 3, 4, 5]))
7
{'total': 15, 'max': 5}

Any est le type d’échappement : une valeur de type Any est compatible avec tous les autres types. À utiliser avec parcimonie, car il désactive la vérification statique pour cette valeur.

TypeVar représente un paramètre de type générique. Il exprime que plusieurs paramètres ou le retour d’une fonction partagent le même type, sans le fixer à l’avance :

from typing import TypeVar

T = TypeVar('T')

def premier(liste: list[T]) -> T:
    """Retourne le premier élément, quel que soit son type."""
    if not liste:
        raise ValueError("La liste est vide.")
    return liste[0]

print(premier([1, 2, 3]))        # int
print(premier(["a", "b", "c"]))  # str
print(premier([3.14, 2.71]))     # float
1
a
3.14

Annotations avancées#

Literal#

Literal exprime qu’une valeur doit être exactement une des valeurs littérales spécifiées :

from typing import Literal

Direction = Literal["nord", "sud", "est", "ouest"]

def deplacer(direction: Direction, pas: int = 1) -> str:
    return f"Déplacement de {pas} vers le {direction}."

print(deplacer("nord", 3))
# mypy signalerait une erreur si on passait "haut" ici
Déplacement de 3 vers le nord.

Final#

Final indique qu’une variable ne doit pas être réassignée. C’est l’équivalent de const dans d’autres langages :

from typing import Final

TAUX_TVA: Final = 0.20
MAX_TENTATIVES: Final[int] = 3

print(f"TVA : {TAUX_TVA * 100:.0f}%")
print(f"Max tentatives : {MAX_TENTATIVES}")
TVA : 20%
Max tentatives : 3

TypedDict#

TypedDict permet d’annoter des dictionnaires dont les clés et les types de valeurs sont connus à l’avance :

from typing import TypedDict

class Utilisateur(TypedDict):
    nom: str
    age: int
    email: str
    actif: bool

def afficher_utilisateur(u: Utilisateur) -> None:
    statut = "actif" if u["actif"] else "inactif"
    print(f"{u['nom']} ({u['age']} ans) — {u['email']} [{statut}]")

alice: Utilisateur = {
    "nom": "Alice Martin",
    "age": 32,
    "email": "alice@exemple.fr",
    "actif": True,
}
afficher_utilisateur(alice)
Alice Martin (32 ans) — alice@exemple.fr [actif]

Protocol#

Protocol (PEP 544) implémente le typage structurel (duck typing statique) : une classe satisfait un protocole si elle possède les méthodes et attributs requis, sans avoir à hériter explicitement du protocole. C’est l’équivalent des interfaces en Go ou en TypeScript :

from typing import Protocol

class Dessinable(Protocol):
    def dessiner(self) -> str:
        ...

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

class Rectangle:
    def dessiner(self) -> str:
        return "□"

class Triangle:
    def dessiner(self) -> str:
        return "△"

def afficher_forme(forme: Dessinable) -> None:
    print(forme.dessiner())

# Toutes ces classes satisfont le protocole Dessinable
# sans en hériter explicitement
for forme in [Cercle(), Rectangle(), Triangle()]:
    afficher_forme(forme)
○
□
△

overload#

@overload permet de déclarer plusieurs signatures pour une même fonction, utile quand le type de retour dépend du type des arguments :

from typing import overload

@overload
def doubler(x: int) -> int: ...
@overload
def doubler(x: str) -> str: ...

def doubler(x):  # Implémentation réelle (sans annotation)
    if isinstance(x, int):
        return x * 2
    return x + x

print(doubler(5))      # int → int
print(doubler("abc"))  # str → str
10
abcabc

Génériques#

Les classes génériques permettent d’écrire du code paramétré par un type, comme list[T] ou dict[K, V] dans la bibliothèque standard.

from typing import TypeVar, Generic

T = TypeVar('T')

class Pile(Generic[T]):
    """Pile LIFO générique."""

    def __init__(self) -> None:
        self._elements: list[T] = []

    def empiler(self, element: T) -> None:
        self._elements.append(element)

    def depiler(self) -> T:
        if not self._elements:
            raise IndexError("La pile est vide.")
        return self._elements.pop()

    def sommet(self) -> T:
        if not self._elements:
            raise IndexError("La pile est vide.")
        return self._elements[-1]

    def __len__(self) -> int:
        return len(self._elements)

    def __repr__(self) -> str:
        return f"Pile({self._elements})"


pile_int: Pile[int] = Pile()
pile_int.empiler(1)
pile_int.empiler(2)
pile_int.empiler(3)
print(f"Pile : {pile_int}, sommet : {pile_int.sommet()}")
print(f"Dépilé : {pile_int.depiler()}, reste : {pile_int}")
Pile : Pile([1, 2, 3]), sommet : 3
Dépilé : 3, reste : Pile([1, 2])

Variance#

La variance décrit comment les relations de sous-typage se propagent à travers les types génériques. Par défaut, un TypeVar est invariant : Pile[int] n’est ni un sous-type ni un super-type de Pile[float]. On peut spécifier :

  • covariant=True : Pile[int] est un sous-type de Pile[float] si int est un sous-type de float. Utilisé pour les types en lecture seule.

  • contravariant=True : inverse. Utilisé pour les types en écriture seule.

from typing import TypeVar

T_co = TypeVar('T_co', covariant=True)

class LecteurGenerique(Generic[T_co]):
    """Producteur de T, donc covariant."""
    def lire(self) -> T_co: ...

mypy#

mypy est le vérificateur de types statique de référence pour Python. Il analyse le code source et signale les incohérences de types sans exécuter le code.

Installation et utilisation de base#

# Installation
pip install mypy
# ou avec uv
uv add --dev mypy

# Vérification d'un fichier
mypy mon_module.py

# Vérification de tout un projet
mypy src/

Configuration mypy.ini#

mypy se configure via mypy.ini, pyproject.toml (section [tool.mypy]) ou .mypy.ini :

# mypy.ini
[mypy]
python_version = 3.12
strict = true
warn_return_any = true
warn_unused_configs = true
ignore_missing_imports = false

[mypy-bibliotheque_externe.*]
ignore_missing_imports = true

Le mode --strict active un ensemble de vérifications supplémentaires : interdiction de Any implicite, vérification des types de retour même pour les fonctions non annotées appelées depuis du code annoté, etc. C’est le mode recommandé pour les nouveaux projets.

Lire les erreurs de mypy#

Hide code cell source

fig, ax = plt.subplots(figsize=(13, 7))
ax.set_xlim(0, 13)
ax.set_ylim(0, 7)
ax.axis('off')
ax.set_title("Évolution de la syntaxe de typage Python", fontsize=14,
             fontweight='bold', pad=12)

versions = ["Python 3.5", "Python 3.9", "Python 3.10", "Python 3.12"]
xs = [1.5, 4.5, 7.5, 10.5]
couleurs = ['#3498db', '#27ae60', '#e67e22', '#8e44ad']

for x, ver, col in zip(xs, versions, couleurs):
    rect = patches.FancyBboxPatch(
        (x - 1.3, 6.0), 2.6, 0.7,
        boxstyle="round,pad=0.12", linewidth=2,
        edgecolor=col, facecolor=col, alpha=0.9
    )
    ax.add_patch(rect)
    ax.text(x, 6.35, ver, ha='center', va='center',
            fontsize=11, fontweight='bold', color='white')

exemples = [
    # (y, Python 3.5, Python 3.9, Python 3.10, Python 3.12)
    (5.2, "List[int]", "list[int]", "list[int]", "list[int]"),
    (4.4, "Dict[str, int]", "dict[str, int]", "dict[str, int]", "dict[str, int]"),
    (3.6, "Optional[str]", "Optional[str]", "str | None", "str | None"),
    (2.8, "Union[int, str]", "Union[int, str]", "int | str", "int | str"),
    (2.0, "Tuple[int, ...]", "tuple[int, ...]", "tuple[int, ...]", "tuple[int, ...]"),
    (1.2, "Type[T]", "type[T]", "type[T]", "type[T]"),
    (0.4, "—", "—", "—", "TypeVarTuple (3.11+)"),
]

labels_gauche = [
    "Liste d'entiers",
    "Dict str→int",
    "Optionnel",
    "Union",
    "Tuple variadique",
    "Méta-type",
    "Nouveau (3.11+)",
]

for i, (y, *valeurs) in enumerate(exemples):
    for j, (x, val) in enumerate(zip(xs, valeurs)):
        col = couleurs[j]
        est_moderne = val in ["str | None", "int | str"] and j >= 2
        bg_col = col if est_moderne else '#ecf0f1'
        txt_col = 'white' if est_moderne else '#2c3e50'
        rect = patches.FancyBboxPatch(
            (x - 1.25, y - 0.28), 2.5, 0.52,
            boxstyle="round,pad=0.06", linewidth=1.2,
            edgecolor=col, facecolor=bg_col, alpha=0.9
        )
        ax.add_patch(rect)
        ax.text(x, y, val, ha='center', va='center',
                fontsize=8, color=txt_col, fontfamily='monospace',
                fontweight='bold' if est_moderne else 'normal')
    ax.text(0.1, y, labels_gauche[i], ha='left', va='center',
            fontsize=8, color='#555555', style='italic')

plt.tight_layout()
plt.show()
_images/2de26bede464e808a8319a130c25c8b8b606a46c9db2cb0ec990da7392663fe8.png

Les erreurs de mypy suivent un format standard :

fichier.py:12: error: Argument 1 to "saluer" has incompatible type "int"; expected "str"  [arg-type]

Le code entre crochets ([arg-type], [return-value], [assignment], etc.) identifie la catégorie d’erreur. On peut supprimer une erreur spécifique avec # type: ignore[code] :

resultat = fonction_externe()  # type: ignore[no-untyped-call]

Intégration VS Code#

L’extension officielle Python de VS Code utilise pylance (basé sur pyright) pour la vérification de types en temps réel. On peut aussi configurer mypy comme vérificateur alternatif via les paramètres python.analysis.typeCheckingMode et mypy.enabled.

Remarque 28

mypy et pyright peuvent produire des diagnostics légèrement différents sur les mêmes fichiers car ils implémentent la spécification de typage de façon indépendante. pyright (utilisé par pylance dans VS Code) est généralement plus rapide et plus strict ; mypy est plus configurable et souvent considéré comme la référence. Pour la cohérence en CI, choisissez l’un des deux et tenez-vous-y.

Exemple 7 (Stratégie d’adoption progressive)

Pour adopter les types dans un projet existant sans friction excessive :

  1. Commencer par annoter les interfaces publiques : fonctions et méthodes publiques des modules principaux.

  2. Activer mypy en mode souple (sans --strict) sur le projet entier.

  3. Corriger les erreurs module par module, en ordre de priorité.

  4. Activer progressivement des options plus strictes (--disallow-untyped-defs, --no-implicit-optional).

  5. Atteindre le mode --strict complet sur les modules les plus critiques.

Cette approche évite le « big bang » d’une migration totale et permet à l’équipe de s’approprier les annotations graduellement.

Résumé#

Ce chapitre a exploré le système d’annotations de types de Python et son écosystème d’outils :

  • Les annotations de types sont optionnelles et progressives ; elles ne changent pas le comportement dynamique de Python mais servent aux outils statiques et à la documentation.

  • La syntaxe de base utilise : pour les paramètres et variables, -> pour les retours. Depuis Python 3.10, X | Y remplace Union[X, Y] et X | None remplace Optional[X].

  • Le module typing fournit TypeVar, Any, Callable, TypedDict, Protocol, Literal, Final, overload et d’autres constructions avancées.

  • Les génériques permettent d’écrire des classes et fonctions paramétrées par un type (Generic[T]), avec un contrôle fin de la variance.

  • mypy est l’outil de vérification statique de référence, configurable via mypy.ini ou pyproject.toml. Le mode --strict est recommandé pour les nouveaux projets.

Dans le chapitre suivant, nous verrons comment écrire et organiser des tests automatisés avec pytest, l’outil de test de facto en Python.