Type hints dans Python et vérification avec mypy

1. Vue d’ensemble

Python est un langage avec typage dynamique, ce qui signifie que vous n’avez pas besoin de déclarer les types de données à l’avance. C’est flexible mais cela peut facilement causer des erreurs à l’exécution lorsque le projet grandit. Les type hints (PEP 484) vous permettent d’annoter les types de données sans modifier le comportement à l’exécution, et mypy lira ces annotations pour détecter les erreurs de type directement pendant que vous codez.

Après avoir lu cet article, vous saurez comment écrire des type hints pour les variables, fonctions, classes, utiliser Optional, Union, TypedDict, les génériques, et intégrer mypy dans votre processus de vérification de code.

2. Type hints de base

2.1. Déclaration de type pour les variables et fonctions

def calculate_total(price: float, quantity: int) -> float:
    return price * quantity

customer_name: str = "Nguyễn Văn A"
total: float = calculate_total(price=15.5, quantity=3)
print(total)
  • price: float, quantity: int sont les types des paramètres d’entrée.
  • -> float est le type de la valeur retournée.
  • customer_name: str est l’annotation pour la variable, ce qui aide l’IDE pour les suggestions et mypy pour vérifier.
46.5

Remarque: Les type hints ne font PAS de conversion de type à l’exécution. Si vous appelez calculate_total("10", 2), Python continue à s’exécuter normalement car l’interpréteur ignore l’annotation — seuls les outils comme mypy signaleront l’erreur.

2.2. Installation de mypy

pip install mypy

Vérifier le fichier :

mypy order_service.py

S’il n’y a pas d’erreur, mypy affiche :

Success: no issues found in 1 source file

3. Optional et Union pour les valeurs pouvant être None

De nombreuses fonctions retournent None quand elles ne trouvent pas de données. Utilisez Optional pour l’indiquer explicitement.

from typing import Optional

class Customer:
    def __init__(self, customer_id: int, email: str) -> None:
        self.customer_id = customer_id
        self.email = email

def find_customer(customer_id: int) -> Optional[Customer]:
    database = {1: Customer(1, "a@example.com")}
    return database.get(customer_id)

customer = find_customer(2)
if customer is not None:
    print(customer.email)
  • Optional[Customer] est équivalent à Union[Customer, None].
  • Si vous appelez customer.email sans vérifier None d’abord, mypy signalera l’erreur Item "None" of "Optional[Customer]" has no attribute "email".
from typing import Union

def format_id(value: Union[int, str]) -> str:
    return str(value).zfill(6)

Union[int, str] permet au paramètre d’être soit int, soit str. À partir de Python 3.10, vous pouvez écrire plus simplement int | str à la place de Union[int, str], et Customer | None à la place de Optional[Customer].

4. Types de collection : list, dict, tuple

from typing import Dict, List, Tuple

def summarize_orders(order_totals: List[float]) -> Tuple[float, int]:
    return sum(order_totals), len(order_totals)

def build_price_table(items: Dict[str, float]) -> Dict[str, float]:
    return {name: price * 1.1 for name, price in items.items()}
  • List[float] signifie une liste de nombres à virgule flottante.
  • Tuple[float, int] signifie un tuple avec exactement 2 éléments : un float puis un int.
  • Dict[str, float] est un dictionnaire avec des clés de type str et des valeurs de type float.

Remarque: À partir de Python 3.9, vous pouvez utiliser directement list[float], dict[str, float] sans importer de typing. Utilisez typing.List/typing.Dict principalement pour la compatibilité avec Python 3.8 et versions antérieures.

5. TypedDict pour les données en forme de dict structuré

Quand vous travaillez avec du JSON ou des dicts avec un schéma fixe, TypedDict aide mypy à vérifier chaque clé.

from typing import TypedDict

class OrderPayload(TypedDict):
    order_id: int
    customer_email: str
    total: float

def create_order(payload: OrderPayload) -> str:
    return f"Order #{payload['order_id']} - {payload['customer_email']} - {payload['total']}"

payload: OrderPayload = {
    "order_id": 101,
    "customer_email": "b@example.com",
    "total": 250.0,
}
print(create_order(payload))

Si vous manquez la clé total ou tapez mal "totl", mypy signale :

error: Missing key "total" for TypedDict "OrderPayload"

C’est un grand avantage par rapport à l’utilisation de Dict[str, Any] — vous contrôlez la structure des données tout en gardant un dict Python pur à l’exécution.

6. Génériques avec TypeVar

Quand vous écrivez une fonction ou une classe qui fonctionne avec plusieurs types de données, utilisez TypeVar pour maintenir la relation entre le type d’entrée et de sortie.

from typing import TypeVar, List

T = TypeVar("T")

def first_item(items: List[T]) -> T:
    return items[0]

first_name: str = first_item(["Hùng", "Lan"])
first_number: int = first_item([10, 20, 30])
  • TypeVar("T") crée un placeholder de type.
  • Quand vous appelez first_item(["Hùng", "Lan"]), mypy déduit que T = str, donc le résultat retourné est str.
  • Quand vous appelez avec List[int], T = int. Grâce à cela, mypy connaît le type de retour exact selon chaque appel, au lieu d’être vague comme Any.

7. Class et Protocol à la place d’interface

Python n’a pas d’interface comme Java, mais Protocol (PEP 544) vous permet de définir le typage structural — vérifier “a-t-il cette méthode ?” au lieu de devoir hériter.

from typing import Protocol

class PaymentGateway(Protocol):
    def charge(self, amount: float) -> bool:
        ...

class VnPayGateway:
    def charge(self, amount: float) -> bool:
        print(f"Charging {amount} via VNPay")
        return True

def process_payment(gateway: PaymentGateway, amount: float) -> bool:
    return gateway.charge(amount)

process_payment(VnPayGateway(), 199000.0)

VnPayGateway n’a pas besoin d’hériter de PaymentGateway — il suffit qu’il ait la méthode charge(self, amount: float) -> bool pour que mypy l’accepte comme satisfaisant le Protocol. C’est une approche très “Pythonic”, qui préserve le duck typing tout en ayant une vérification statique.

8. Configuration de mypy pour le projet

Ajoutez la dépendance à requirements-dev.txt :

mypy==1.11.2

Créez un fichier de configuration mypy.ini à la racine du projet :

[mypy]
python_version = 3.12
disallow_untyped_defs = True
warn_return_any = True
warn_unused_ignores = True
ignore_missing_imports = True
  • disallow_untyped_defs = True oblige toutes les fonctions à avoir des type hints, évitant les oublis.
  • warn_return_any = True signale quand une fonction retourne Any silencieusement.
  • ignore_missing_imports = True est utile quand vous utilisez des bibliothèques tierces sans type stub.

Exécutez la vérification sur l’ensemble du projet :

mypy src/

9. Écrire des tests pour le comportement avec type hints

Les type hints ne remplacent pas les tests, mais aident les tests à être plus clairs sur les types de données d’entrée/sortie. Utilisez pytest :

<!-- requirements-dev.txt format équivalent, juste pour illustrer la structure de la dépendance -->
pytest==8.3.2
from order_service import calculate_total

def test_calculate_total_returns_correct_sum() -> None:
    result = calculate_total(price=20.0, quantity=5)

    assert result == 100.0

def test_calculate_total_with_zero_quantity() -> None:
    result = calculate_total(price=50.0, quantity=0)

    assert result == 0.0

Exécutez les tests :

pytest -v
test_order_service.py::test_calculate_total_returns_correct_sum PASSED
test_order_service.py::test_calculate_total_with_zero_quantity PASSED

Vous pouvez combiner les deux étapes dans votre CI : exécutez mypy d’abord pour attraper les erreurs de type, puis pytest pour vérifier la logique métier. Les deux outils se complètent — mypy attrape les erreurs “mauvais type de données”, pytest attrape les erreurs “mauvaise logique”.

10. Erreurs courantes et quand mypy n’est pas nécessaire

  • Oublier de mettre à jour le type hint quand on change la logique : par exemple, une fonction passe de retourner Optional[Customer] à toujours retourner Customer, mais vous oubliez de modifier l’annotation — mypy ne signalera pas l’erreur si le code correspond toujours au type ancien plus large, ce qui rend les informations d’annotation inexactes.
  • Utiliser Any partout : déclarer def process(data: Any) -> Any prive mypy de la capacité à vérifier, ce qui revient à désactiver le typage statique à cet endroit.
  • Annotation incorrecte avec les bibliothèques tierces sans stub : de nombreux anciens packages n’ont pas publié de type stub, mypy signalera error: Cannot find implementation or library stub. Vous gérez cela avec ignore_missing_imports ou en installant types-<package> s’il existe.
  • Quand NE PAS utiliser mypy strictement : pour un petit script, un script unique à usage unique (throwaway script), ou un prototype rapide pour tester une idée — forcer des type hints complets peut ralentir votre vitesse d’écriture sans bénéfice proportionnel.

Remarque: mypy fait uniquement une analyse statique (static analysis) du code source, il ne l’exécute pas. Il ne peut pas attraper les erreurs qui dépendent des données d’exécution, par exemple vérifier si une valeur lue depuis un fichier de configuration ou une API externe a vraiment le type déclaré.

11. Conclusion

Les type hints rendent le code Python plus lisible, plus facile à maintenir et réduisent les erreurs dues aux confusions de type, tandis que mypy transforme ces annotations en outil de vérification réel au lieu d’être juste de la documentation. Vous devriez commencer à appliquer progressivement aux fonctions publiques, modules importants, puis étendre graduellement à tout le projet avec la configuration disallow_untyped_defs. Combiner mypy dans votre CI avec des tests utilisant pytest est l’approche la plus pratique pour garder la flexibilité de Python tout en ayant un niveau de sécurité proche des langages à typage statique.

pythontype-hintsmypystatic-typing