Skip to main content
Niveau : Avancé

Chapitre 34 : Le Typage Statique avec typing

Objectif

Ce module a pour but de vous introduire au typage statique en Python (aussi appelé "Type Hinting"). Vous apprendrez à annoter votre code avec des indications de type, à utiliser le module typing pour des types complexes, et à comprendre les avantages de cette pratique pour la robustesse et la lisibilité du code.

1. Qu'est-ce que le Typage Statique ?

Python est un langage à typage dynamique. Cela signifie que le type d'une variable est déterminé à l'exécution, et qu'une même variable peut contenir des objets de types différents au cours de la vie du programme.

x = 10      # x est un int
x = "hello" # Maintenant, x est un str

Le typage statique, à l'inverse, est un système où les types des variables sont connus avant l'exécution (à la "compilation").

Python, depuis la version 3.5, permet d'ajouter des indications de type (type hints) optionnelles au code. C'est une forme de typage statique graduel.

Important : L'interpréteur Python, par défaut, ignore complètement ces annotations. Elles n'ont aucun impact sur l'exécution du code. Leur utilité vient d'outils externes appelés analyseurs de type statique (comme mypy, pyright, pyre) qui lisent ces annotations pour détecter des erreurs de type avant même que vous ne lanciez le programme.

2. Syntaxe de Base

a. Annoter des Variables

On utilise les deux-points (:) après le nom de la variable, suivi du type.

nom: str = "Alice"
age: int = 30
est_majeur: bool = True
prix: float = 19.99

b. Annoter des Fonctions

  • Pour les arguments : nom_argument: type
  • Pour la valeur de retour : -> type_retour
def saluer(nom: str) -> str:
return f"Bonjour, {nom}"

def addition(a: int, b: int) -> int:
return a + b

# Pour une fonction qui ne retourne rien, on utilise -> None
def afficher_message(message: str) -> None:
print(message)

3. Le Module typing

Pour des types plus complexes que int, str, etc., on utilise le module typing.

a. Types Composés (List, Dict, Tuple, Set)

Depuis Python 3.9+, on peut utiliser les types standards (list, dict) directement. Pour les versions antérieures, il faut importer List, Dict, etc., depuis typing.

# Python 3.9+
nombres: list[int] = [1, 2, 3]
scores: dict[str, int] = {"Alice": 10, "Bob": 8}
coordonnees: tuple[int, float, int] = (10, 20.5, 5)

# Python < 3.9 (toujours valide)
from typing import List, Dict, Tuple, Set

nombres: List[int] = [1, 2, 3]
scores: Dict[str, int] = {"Alice": 10, "Bob": 8}

b. Union et Optional

  • Union[type1, type2, ...] : Indique qu'une variable peut être de l'un des types listés.
  • Optional[type] : Un raccourci pour Union[type, None]. Indique qu'une variable peut être du type spécifié ou None.
from typing import Union, Optional

# id peut être un int ou un str
identifiant: Union[int, str] = 123
identifiant = "user-abc"

# nom_utilisateur peut être une chaîne ou None
nom_utilisateur: Optional[str] = "Alice"
nom_utilisateur = None

c. Any

Any est un type spécial qui indique à l'analyseur de type que n'importe quel type est autorisé. C'est une "porte de sortie" du système de typage. On l'utilise quand on ne peut vraiment pas connaître le type ou pour migrer progressivement un projet vers le typage statique.

from typing import Any

def traiter_donnees_inconnues(data: Any) -> Any:
# L'analyseur de type n'émettra aucune alerte ici
print(data)
return data

d. Callable

Pour annoter des fonctions passées en argument. La syntaxe est Callable[[type_arg1, type_arg2], type_retour].

from typing import Callable

def appliquer_operation(a: int, b: int, operation: Callable[[int, int], int]) -> int:
return operation(a, b)

def addition(x: int, y: int) -> int:
return x + y

resultat = appliquer_operation(10, 5, addition) # OK

4. Utiliser un Analyseur de Type : mypy

mypy est l'analyseur de type statique le plus populaire pour Python.

  1. Installation :

    pip install mypy
  2. Créer un fichier de test test_types.py :

    def saluer(nom: str) -> str:
    return f"Bonjour, {nom}"

    # Appel correct
    saluer("Alice")

    # Appel incorrect
    # mypy va détecter que 123 n'est pas un str
    saluer(123)
  3. Lancer mypy :

    mypy test_types.py
  4. Résultat de mypy :

    test_types.py:8: error: Argument 1 to "saluer" has incompatible type "int"; expected "str"
    Found 1 error in 1 file (checked 1 source file)

    mypy a trouvé l'erreur avant même l'exécution du code !

5. Avantages du Typage Statique

  1. Détection précoce des bugs : C'est l'avantage principal. Les erreurs de type sont parmi les plus courantes en programmation.
  2. Amélioration de la lisibilité et de la documentation : Les annotations de type servent de documentation. On sait immédiatement ce qu'une fonction attend comme arguments et ce qu'elle retourne.
  3. Meilleur support des IDE : Les éditeurs de code comme VS Code utilisent les annotations pour fournir une auto-complétion plus intelligente, des refactorings plus sûrs et une meilleure navigation dans le code.
  4. Architecture plus robuste : Le fait de penser aux types dès la conception pousse à créer des interfaces de données plus claires et une architecture plus solide.

6. Annotations pour les Classes Personnalisées

On peut utiliser les classes que l'on définit comme des types.

class Personne:
def __init__(self, nom: str, age: int):
self.nom = nom
self.age = age

def souhaiter_anniversaire(p: Personne) -> None:
p.age += 1
print(f"Joyeux anniversaire {p.nom} ! Vous avez maintenant {p.age} ans.")

# mypy vérifiera que l'objet passé à la fonction est bien une instance de Personne
# (ou une sous-classe compatible).
alice = Personne("Alice", 30)
souhaiter_anniversaire(alice)

Références circulaires et Forward References

Parfois, une classe a besoin de s'annoter elle-même (ex: une méthode qui retourne une nouvelle instance de la classe), ou deux classes se référencent mutuellement. Si le type n'est pas encore défini au moment où Python lit l'annotation, cela cause une NameError.

La solution est d'utiliser une "forward reference", en mettant le nom du type entre guillemets.

class Node:
def __init__(self, valeur: int):
self.valeur = valeur
# Le type 'Node' n'est pas encore complètement défini ici.
# On utilise donc des guillemets.
self.suivant: Optional['Node'] = None

def set_suivant(self, autre_node: 'Node') -> None:
self.suivant = autre_node

Exercice : Annotation d'une Fonction de Traitement de Données

Objectif

Cet exercice a pour but de vous faire pratiquer l'ajout d'annotations de type à une fonction existante et d'utiliser mypy pour vérifier la correction de votre code.

Contexte

Vous disposez d'une fonction qui traite une liste de dictionnaires représentant des utilisateurs. Chaque dictionnaire contient un nom (chaîne), un âge (entier) et une liste d'emails (qui peut être vide). La fonction doit filtrer les utilisateurs pour ne garder que ceux qui sont majeurs (18 ans ou plus) et qui ont au moins un email.

Votre tâche est d'ajouter des annotations de type précises à cette fonction et de vérifier votre travail avec mypy.

Énoncé

Étape 1 : Créer le fichier et la fonction

Créez un fichier nommé process_users.py et copiez-y le code suivant :

# process_users.py

def process_users(users):
"""
Filtre une liste d'utilisateurs pour ne garder que les adultes avec au moins un email.
"""
processed_list = []
for user in users:
if user['age'] >= 18 and user['emails']:
processed_list.append(user)
return processed_list

# --- Données de test ---
sample_users = [
{
"name": "Alice",
"age": 30,
"emails": ["alice@example.com"]
},
{
"name": "Bob",
"age": 17,
"emails": ["bob@example.com"]
},
{
"name": "Charlie",
"age": 40,
"emails": [] # Pas d'email
},
{
"name": "David",
"age": 25,
"emails": ["david@example.com", "d.d@work.com"]
}
]

if __name__ == "__main__":
adult_users = process_users(sample_users)
print("Utilisateurs adultes avec email :")
for user in adult_users:
print(f"- {user['name']} (Age: {user['age']})")

Étape 2 : Ajouter les annotations de type

  1. Importez les types nécessaires depuis le module typing. Vous aurez besoin de List et Dict. Pour représenter un dictionnaire avec des clés de type str et des valeurs de n'importe quel type, vous pouvez utiliser Dict[str, any]. Cependant, pour être plus précis, on peut définir la structure de l'utilisateur. Pour cet exercice, nous allons utiliser une approche simple avec Dict[str, any] d'abord, puis une plus avancée.

  2. Annotez la fonction process_users.

    • L'argument users est une liste de dictionnaires. Annotez-le en conséquence.
    • La fonction retourne également une liste de dictionnaires. Annotez la valeur de retour.
  3. (Optionnel, plus avancé) Créez un alias de type pour l'utilisateur. Pour améliorer la lisibilité, vous pouvez définir un alias de type pour la structure d'un dictionnaire utilisateur en utilisant TypeAlias (Python 3.10+) ou une simple assignation.

    from typing import TypeAlias, List, Dict, Union

    # Pour être très précis
    UserDict: TypeAlias = Dict[str, Union[str, int, List[str]]]

    Utilisez ensuite UserDict dans vos annotations.

Étape 3 : Vérifier avec mypy

  1. Installez mypy si ce n'est pas déjà fait :

    pip install mypy
  2. Exécutez mypy sur votre fichier :

    mypy process_users.py

    Si vos annotations sont correctes, mypy devrait afficher : Success: no issues found in 1 source file.

  3. Introduisez une erreur de type pour voir mypy en action.

    • Par exemple, essayez d'appeler process_users avec une liste de chaînes de caractères au lieu d'une liste de dictionnaires.
    # Ajoutez cette ligne à la fin de votre bloc if __name__ == "__main__":
    process_users(["user1", "user2"])
    • Relancez mypy. Il devrait maintenant signaler une erreur.

Résultat Attendu

Votre fichier process_users.py doit être entièrement annoté. mypy ne doit trouver aucune erreur sur la version correcte du code, mais doit en trouver une lorsque vous introduisez un appel avec un type incorrect.

Sortie de mypy après avoir introduit l'erreur :

process_users.py:45: error: Argument 1 to "process_users" has incompatible type "List[str]"; expected "List[Dict[str, object]]"  [arg-type]
Found 1 error in 1 file (checked 1 source file)

(Note : Le type attendu peut varier légèrement selon la précision de votre annotation, par exemple List[Dict[str, Any]] ou List[UserDict]).

Solution
# process_users.py

from typing import List, Dict, Any, Union

# On peut définir un alias pour rendre le code plus lisible.
# Union[...] permet de spécifier les différents types possibles pour les valeurs.
User = Dict[str, Union[str, int, List[str]]]

def process_users(users: List[User]) -> List[User]:
"""
Filtre une liste d'utilisateurs pour ne garder que les adultes avec au moins un email.
"""
processed_list: List[User] = []
for user in users:
# mypy peut inférer que user['age'] est un int et user['emails'] une liste,
# mais seulement si l'annotation de User est précise.
# Si on avait utilisé Dict[str, Any], mypy ne pourrait pas être sûr.
if user['age'] >= 18 and user['emails']:
processed_list.append(user)
return processed_list

# --- Données de test ---
sample_users: List[User] = [
{
"name": "Alice",
"age": 30,
"emails": ["alice@example.com"]
},
{
"name": "Bob",
"age": 17,
"emails": ["bob@example.com"]
},
{
"name": "Charlie",
"age": 40,
"emails": [] # Pas d'email
},
{
"name": "David",
"age": 25,
"emails": ["david@example.com", "d.d@work.com"]
}
]

if __name__ == "__main__":
adult_users = process_users(sample_users)
print("Utilisateurs adultes avec email :")
for user in adult_users:
print(f"- {user['name']} (Age: {user['age']})")

# --- Ligne pour introduire une erreur de type ---
# Décommentez la ligne suivante pour voir mypy lever une erreur
# process_users(["user1", "user2"])

Annotation alternative plus simple (mais moins précise) : Si vous ne voulez pas définir un type User complexe, vous pouvez utiliser Any ou object.

from typing import List, Dict, Any

def process_users(users: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
# ...

Cette version est aussi valide pour mypy, mais elle offre moins de sécurité à l'intérieur de la fonction, car mypy ne connaîtra pas le type des valeurs comme user['age'].