Python est célèbre pour son typage dynamique : il suffit de créer une variable, de lui assigner une valeur, et Python va en inférer le type.
Depuis la version 3.5, Python supporte les "Type Hints", que l'on pourrait traduire par "indices de type". Mais ce n'est pas une contrainte ; il faut plutôt les voir comme un outil, une aide pour la maintenabilité du code.
Le typage avec Python est en constante évolution, notamment avec les nombreuses améliorations apportées jusqu'à la version 3.14.
Pourquoi utiliser le typage en Python ?
Avant de plonger dans le vif du sujet, il faut comprendre la démarche :
-
Éviter les bugs, se rendre compte avant d'exécuter le code que l'on passe une chaîne de caractères à une fonction qui attendait un entier
-
Améliorer l'autocomplétion, car l'IDE sait exactement ce que contient la variable et propose les bonnes méthodes
-
Documenter son code, car le typage indique ce qu'une fonction attend et aussi ce qu'elle retourne
Les bases
Pour typer, il suffit d'utiliser le symbole : afin de définir le type et -> pour le type de retour d'une fonction.
# Typage de variables simples age: int = 25 nom: str = "Patrick" est_admin: bool = False # Typage d'une fonction def saluer(nom: str) -> str: return f"Bonjour {nom}"
À noter
Le typage en Python reste indicatif à l'exécution. Vous pouvez très bien passer un entier à la fonction saluer, Python ne "crashera" pas au moment de l'appel. Pour vérifier les erreurs, nous utilisons des outils comme Mypy ou Pyright.
Erreur de typage signalée dans VsCode via Pyright
L'évolution majeure de Python 3.9 à 3.10
Avant Python 3.9, nous devions importer les types complexes depuis le module typing. Mais depuis l'arrivée de la version 3.9, c'est beaucoup plus simple.
La fin du module typing pour les collections (3.9+)
Avant Python 3.9 :
from typing import List, Dict scores: List[int] = [10, 20, 30] utilisateurs: Dict[str, int] = {"Patrick": 1, "Sebastien": 2}
Mais maintenant, vous pouvez utiliser les types natifs en minuscules :
scores: list[int] = [10, 20, 30] utilisateurs: dict[str, int] = {"Patrick": 1, "Sebastien": 2}
L'opérateur "Union" | (Python 3.10+)
L'ancienne syntaxe utilisait :
-
Union[str, int]pour indiquer que cette variable peut être de type chaîne de caractères ou entier -
Optional[str]pour indiquer que cette variable est de type chaîne de caractères, mais qu'elle peut aussi valoirNone
from typing import Union, Optional def traiter_id(id_user: Union[str, int]) -> None: print(f"ID traité : {id_user}") def chercher_nom(id_user: int) -> Optional[str]: if id_user == 1: return "Patrick" return None
Avec Python 3.10, on remplace ces mots-clés par un simple "pipe" (|) :
def traiter_id(id_user: str | int) -> None: print(f"ID traité : {id_user}") def chercher_nom(id_user: int) -> str | None: if id_user == 1: return "Patrick" return None
Les types avancés
De manière plus avancée, le module typing met des outils à disposition pour certains cas.
Comment typer les fonctions passées en argument avec Callable ?
Pour typer une fonction passée en argument à une autre fonction, il faut utiliser Callable. On précise d'abord le type des arguments dans une liste, puis le type de retour.
from typing import Callable # On définit notre alias : toute fonction valide devra prendre (int, float) et renvoyer un bool ActionType = Callable[[int, float], bool] # 1. On crée une vraie fonction def verifier_seuil(score: int, multiplicateur: float) -> bool: return (score * multiplicateur) > 50.0 # 2. Notre fonction principale exige une fonction respectant la signature ActionType def executer_action(action: ActionType, utilisateur: str) -> None: resultat = action(10, 2.5) if resultat: print(f"L'action de {utilisateur} a réussi !") # 3. On appelle executer_action en lui passant notre fonction (sans les parenthèses) executer_action(verifier_seuil, "Patrick")
À noter
On pourrait définir ce type inline si besoin : def executer_action_directe(action: Callable[[int, float], bool], utilisateur: str) -> None:
Comment restreindre aux valeurs exactes avec Literal ?
Parfois, typer avec int ou str est trop large. Imaginons une fonction qui modifie les permissions : on pourrait vouloir qu'elle n'accepte que des mots précis comme "admin" ou "éditeur", mais rien d'autre. Literal est fait pour cela :
from typing import Literal # On définit un type qui n'accepte que ces trois chaînes de caractères exactes Role = Literal["admin", "editeur"] def assigner_role(nom: str, role: Role) -> None: print(f"Le rôle {role} a été assigné à {nom}.") # Ceci est tout à fait valide : assigner_role("Sebastien", "admin") # "dictateur bienveillant" n'est pas autorisé par notre Literal : # assigner_role("Patrick", "dictateur bienveillant")
Erreur de typage signalée
Comment typer les dictionnaires avec TypedDict ?
De manière classique, on typera un dictionnaire avec dict[str, int], où toutes les clés sont des chaînes de caractères et toutes les valeurs des entiers. Mais comment faire si vous avez besoin de manipuler des données JSON où chaque valeur peut avoir un type différent ?
Dans ce cas, on utilise TypedDict :
from typing import TypedDict class Utilisateur(TypedDict): nom: str age: int actif: bool user_valide: Utilisateur = { "nom": "Sebastien", "age": 30, "actif": True } # Erreur pour ce dictionnaire car : # 1. Il manque la clé "actif" # 2. Le type de l'âge est mauvais (str au lieu de int) user_invalide: Utilisateur = {"nom": "Patrick", "age": "trente"}
Les méthodes d'instance fluides avec Self
Une méthode d'instance fluide est une méthode qui retourne self à la fin de son exécution, ce qui permet d'enchaîner des appels sur un même objet.
from typing import Self class RequeteAPI: def __init__(self) -> None: self.url = "" self.headers: dict[str, str] = {} def set_url(self, url: str) -> Self: self.url = url return self def ajouter_header(self, cle: str, valeur: str) -> Self: self.headers[cle] = valeur return self def envoyer(self) -> None: print(f"Envoi de la requête à {self.url} avec les headers : {self.headers}") # L'auto-complétion de votre éditeur fonctionne après chaque point ! requete = ( RequeteAPI() .set_url("https://api.monsite.com/utilisateurs/patrick") .ajouter_header("Authorization", "Bearer jeton123") .ajouter_header("Accept", "application/json") ) requete.envoyer()
Gérer les signatures multiples avec @overload
Imaginez une fonction qui renvoie un entier si on lui donne un entier, et une chaîne de caractères si on lui donne une chaîne de caractères.
En écrivant la signature finale def doubler(valeur: int | str) -> int | str:, un outil d'analyse comme Mypy ou Pyright sera perdu. Si on lui passe un entier, il pourrait penser que le résultat peut être une chaîne de caractères, par exemple.
C'est ici que @overload entre en jeu.
from typing import overload # 1. Promesse n°1 pour l'analyseur : si on donne un entier, on récupère un entier. @overload def doubler(valeur: int) -> int: ... # 2. Promesse n°2 pour l'analyseur : si on donne une chaîne, on récupère une chaîne. @overload def doubler(valeur: str) -> str: ... # 3. La VÉRITABLE fonction, celle qui sera exécutée par Python. # Sa signature englobe tous les cas possibles, et elle contient le code réel. def doubler(valeur: int | str) -> int | str: return valeur * 2 if isinstance(valeur, int) else valeur + valeur # Grâce aux promesses @overload au-dessus, votre éditeur de code # sait désormais avec 100% de certitude que resultat_1 est un entier. resultat_1 = doubler(10) # Et il sait avec 100% de certitude que resultat_2 est une chaîne. resultat_2 = doubler("Patrick")
Quand on survole resultat_1, l'éditeur nous indique le bon type de retour :
Type de retour au survol
Les nouveautés apportées par Python 3.12
Le mot-clé type pour les alias
Précédemment, nous avons créé un alias : ActionType = Callable[[int, float], bool]. Ici, c'est une simple assignation. Mais on ne sait pas forcément si c'est une "vraie" variable ou un alias de type.
Python 3.12 permet d'utiliser le mot-clé type. En plus d'améliorer la lisibilité du code pour les développeurs, il crée un objet <class 'typing.TypeAliasType'>.
Avant Python 3.12 :
Coordonnees = tuple[float, float] Identifiant = int | str
Depuis Python 3.12 :
type Coordonnees = tuple[float, float] type Identifiant = int | str
La nouvelle syntaxe générique
Qu'est-ce qu'un type générique ? Prenons l'exemple d'une fonction qui doit extraire le premier élément d'une liste. Si on lui donne une liste d'entiers, elle renverra un entier. Si on lui donne une liste de chaînes de caractères, elle renverra une chaîne de caractères.
Comment indiquer à notre vérificateur que le type de retour dépend du type passé en entrée ? On utilise une variable de type.
Par convention, on l'appelle T, mais ce n'est qu'une étiquette temporaire. On pourrait utiliser un nom comme A, D, Patrick... Un peu comme on choisirait la lettre i dans une boucle for 😁.
Avant Python 3.12, il fallait importer TypeVar et instancier une variable en lui passant son propre nom sous forme de chaîne de caractères : T = TypeVar('T'). En effet, avant cette version, Python n'avait aucun moyen de connaître le nom de la variable situé à gauche du signe =.
from typing import TypeVar # Python évalue à droite en premier : TypeVar('T') est créé AVANT d'être assigné à T. # On doit donc lui passer son propre nom en chaîne, pour qu'il puisse l'afficher # dans les messages d'erreur de Pyright/mypy. T = TypeVar('T') def premier_element(liste: list[T]) -> T: return liste[0]
Depuis Python 3.12, cette opération est bien plus simple : plus besoin d'import ni de définition préalable. On utilise les crochets après le nom de la fonction pour déclarer notre variable à la volée :
# On annonce "Element" entre crochets, puis on l'utilise dans la signature def premier_element[Element](liste: list[Element]) -> Element: return liste[0] nombres = [10, 20, 30] # L'analyseur voit qu'on passe list[int]. Il remplace mentalement "Element" par "int", # et sait donc que resultat_nombre sera de type "int" ! resultat_nombre = premier_element(nombres) mots = ["Patrick", "Sebastien"] # Ici, il remplace "Element" par "str". resultat_mot = premier_element(mots)
Pyright reconnait le bon type
Les nouveautés de Python 3.13
TypeIs : affiner vos analyses statiques
Avant Python 3.13, pour indiquer à un analyseur statique qu'une variable est d'un certain type après une vérification, on utilisait TypeGuard.
Toutefois, TypeGuard ne fonctionnait que dans un sens. Si la fonction renvoyait True, l'analyseur comprenait le type ; mais si elle renvoyait False, il ne savait rien en déduire.
from typing import TypeGuard def est_chaine(valeur: object) -> TypeGuard[str]: return isinstance(valeur, str) def traiter(donnee: str | int) -> None: if est_chaine(donnee): print(donnee.upper()) # ✅ L'analyseur sait que c'est un str else: # L'analyseur ne sait pas... str ou int ? resultat = donnee + 10
Avec TypeGuard.
Python 3.13 introduit TypeIs, qui permet un raisonnement dans les deux sens.
from typing import TypeIs def est_chaine(valeur: object) -> TypeIs[str]: return isinstance(valeur, str) def traiter(donnee: str | int) -> None: if est_chaine(donnee): # True → l'analyseur sait que c'est un str print(donnee.upper()) else: # False → l'analyseur sait que ce n'est PAS un str. # Comme le type de départ était "str | int", # par déduction logique, ce ne peut être qu'un int. resultat = donnee + 10 print(resultat)
Avec TypeIs.
À noter
Pourquoi object pour le typage ? object est la classe de base de toutes les autres : str, int, list, vos propres classes... Annoter avec object signifie que cette fonction accepte n'importe quel type tout en restant surveillée par Mypy ou Pyright, notre analyseur statique. C'est différent de Any, qui désactive les vérifications. object est donc adapté pour une fonction de garde.
ReadOnly pour protéger les champs d'un TypedDict
Un dictionnaire étant muable, rien ne vous empêche de remplacer les valeurs d'une clé existante. Mais parfois, on voudrait verrouiller le dictionnaire lorsque l'on manipule des données. Python 3.13 permet l'utilisation de ReadOnly.
from typing import TypedDict, ReadOnly class Configuration(TypedDict): # Le nom de l'utilisateur ne doit JAMAIS changer en cours de route utilisateur: ReadOnly[str] # Le mode debug, en revanche, peut être activé ou désactivé debug: bool # On initialise notre configuration avec notre ami Patrick config: Configuration = {"utilisateur": "Patrick", "debug": True} # Modifier le mode debug est autorisé : config["debug"] = False # MAIS si vous essayez de modifier une clé ReadOnly... # config["utilisateur"] = "Sebastien" # L'analyseur de code (Mypy/Pyright) signalera une erreur de type, car "utilisateur" est en lecture seule.
Comme tous les outils du module typing, ce n'est QUE du typage : techniquement, rien ne vous empêche de réellement modifier la valeur !
Les nouveautés de Python 3.14
Python 3.14 permet l'évaluation différée. Je m'explique : avant la version 3.14, Python essayait de valider immédiatement chaque type qu'il croisait. On parlera de référence anticipée : le problème se pose quand on veut annoter un type qui n'est pas encore défini au moment où Python lit la ligne.
# Python lit la ligne ci-dessous et voit "User". # Il ne sait pas encore ce qu'est un "User" ! def create_user(name: str) -> User: return User(name) # La définition de la classe arrive après dans le fichier class User: def __init__(self, name: str) -> None: self.name = name user = create_user("Patrick") print(user.name) """ Traceback (most recent call last): File "/Users/gabrieltrouve/Pro/sandbox/main.py", line 3, in <module> def create_user(name: str) -> User: ^^^^ NameError: name 'User' is not defined """
Il était possible de contourner le problème en important simplement from __future__ import annotations en début de fichier.
Mais depuis la version 3.14, ce code ne pose plus de problème :
def create_user(name: str) -> User: return User(name) class User: def __init__(self, name: str) -> None: self.name = name user = create_user("Patrick") print(user.name)
Le typage structurel avec Protocol
Qu'est-ce que le Duck Typing ?
Vous avez déjà entendu cette phrase ? « Si ça marche comme un canard et cancane comme un canard, c'est un canard. » Peu importe la classe de notre objet : tant qu'il possède une méthode dessiner(), on peut l'utiliser dans une fonction d'affichage.
class Cercle: def dessiner(self): print("Je dessine un cercle.") class BoutonWeb: def dessiner(self): print("J'affiche un bouton.") def afficher(element): element.dessiner() # Python s'en fiche de la classe, il cherche juste la méthode afficher(Cercle()) afficher(BoutonWeb())
Il n'y a aucun lien entre Cercle et BoutonWeb, mais cela fonctionne quand même, car les deux possèdent la méthode dessiner.
Le problème avec les vérificateurs de types
Avant Python 3.8, pour qu'un vérificateur comprenne ce mécanisme de duck typing, il fallait forcer les classes à hériter d'une classe abstraite commune.
Prenons un exemple où BoutonWeb proviendrait d'une bibliothèque externe (totalement inventée pour l'exemple) appelée ui_lib :
from abc import ABC, abstractmethod class Dessinable(ABC): @abstractmethod def dessiner(self) -> None: ... # Ta propre classe : tu peux modifier son code, pas de problème class Cercle(Dessinable): # ← héritage obligatoire def dessiner(self) -> None: print("Patrick dessine un cercle.") # BoutonWeb vient d'une librairie externe (ui_lib) # Son code source : # # class BoutonWeb: # def dessiner(self) -> None: # print("Sébastien affiche un bouton.") # # Elle a bien une méthode dessiner(), mais elle n'hérite pas de Dessinable. # L'analyseur statique se plaint, même si le code fonctionne parfaitement à l'exécution. from ui_lib import BoutonWeb def afficher_element(element: Dessinable) -> None: element.dessiner() afficher_element(Cercle()) # OK afficher_element(BoutonWeb()) # Pylance se plaint : BoutonWeb n'est pas un Dessinable
Je me suis même amusé à recréer l'exemple complet :
Démontrer le problème avec abc et une librairie externe.
Impossible de faire hériter BoutonWeb de Dessinable sans modifier le code de la bibliothèque. Et pour nos propres classes, on se retrouve à ajouter un héritage dont on n'a pas forcément besoin, simplement pour satisfaire notre analyseur.
Utiliser Protocol
Protocol permet de définir un contrat quant aux méthodes attendues sans imposer d'héritage.
Reprenons le même exemple en utilisant Protocol :
from typing import Protocol from ui_lib import BoutonWeb # toujours la même lib externe, rien n'a changé de son côté # Le contrat : "avoir une méthode dessiner()" class Dessinable(Protocol): def dessiner(self) -> None: ... # Ta propre classe, sans héritage forcé class Cercle: def dessiner(self) -> None: print("Patrick dessine un joli cercle.") # La fonction attend un Dessinable def afficher_element(element: Dessinable) -> None: element.dessiner() afficher_element(Cercle()) # L'analyseur statique est content afficher_element(BoutonWeb()) # L'analyseur statique est content aussi et pourtant on n'a rien changé dans ui_lib
Dessinable est une classe, mais elle ne sert pas à l'héritage. Son unique rôle est de typer le paramètre de la fonction. On ne touche ni à Cercle, ni à BoutonWeb, et tout se joue dans la signature de afficher_element.
D'ailleurs, Cercle et BoutonWeb ne se connaissent pas : elles n'héritent pas d'une classe commune et ignorent même l'existence de Dessinable.
Notre analyseur statique, Pyright dans mon cas, vérifie uniquement la structure : la méthode existe-t-elle ? Oui, tout est bon 😎.
Comment vérifier ses types en Python ? Mypy et Pyright
Python n'utilise pas les annotations à l'exécution. Il faut un vérificateur de types statique : un outil que l'on lance sur le code pour détecter les erreurs avant de l'exécuter.
Deux outils dominent :
-
Mypy, le pionnier en la matière
# Installer mypy pip install mypy # Analyser son script mypy mon_script.py -
Pyright, développé par Microsoft
À noter
Personnellement, j'utilise Pyright via l'extension Pylance dans VS Code, ce qui permet un feedback immédiat pendant que l'on code.