Skip to main content

Si on peut dire que la librairie standard Python est stable, on ne peut pas en dire autant de la partie typage qui est en pleine effervescence.

Pour mémoire, le typage en Python est optionnel, et c’est très bien pour les petits projets et les scripts. Dès que le projet prend de l’envergure, le typage des paramètres des fonctions aide à la rigueur et force à se poser les bonnes questions sur les flux de données dans le programme.

Version 3.9

Dans la version 3.9 de Python, il y a une seule évolution dans le typage, mais qui va considérablement améliorer la lisibilité et simplifier le code dédié au typage : on peut désormais utiliser les types « built-ins » (list, disc…) pour déclarer notre typage. 🎉

class Foo:
    def add_items(self, items: list[str]) -> None:
			    ...

Je ne sais pas pour vous, mais pour moi ça réduit tellement le ticket d’entrée du typage que ça ne me dérangerais pas de l’intégrer même dans des petits scripts.

Version 3.10

Depuis la version 3.10 de Python, on peut désormais utiliser l’opérateur | pour déclarer des unions de types (la fonction accepte plusieurs types différents, voir les rendre optionnels) :

class Foo:
    def set_color(self, color: str|Color|None) -> None:
        ...

    def get_color(self) -> Color|None:
			    ...

Un gros effort a également été fait dans cette version pour proposer une généricité dans le typage. Par exemple quand on défini un décorateur, on ne connaît pas toujours à l’avance le type de la fonction décorée. Dorénavant, on peut utiliser ParamSpec pour dire que c’est un type qu’on ne connaît pas.

Un peu de clarté a aussi été apportée avec l’ajout de TypeAlias qui permet de donner un nom explicite à un type complexe.

StrCache: TypeAlias = 'Cache[str]'

Pour créer des petites fonctions qui vérifient le type (is_bool, is_string, etc…), les TypeGuards ont été introduits. Les TypeGuards sont utilisés pour une pratique assez complexe appelée rétrécissement de type. Cette dernière est utilisée pour les fonctions qui acceptent des variables d’entrée avec plusieurs types possibles. TypeGuard permet alors de mettre en place une vérification de type sur les variables d’entrée de la fonction.

🔎L’utilisation des TypeGuards n’est pas très simple, je vous invite donc à aller regarder la documentation Python plus en détails si cela vous intéresse.

Version 3.11

Toujours dans les ajouts complexes, la version 3.11 a introduit des Variadic générique pour gérer des ensembles d’éléments avec une taille fixes (un tenseur avec une taille fixée par exemple).

Un générique permet de préserver un type entre l’entrée et la sortie d’une fonction. Par exemple, si l’on prend la fonction de copie d’une variable, celle-ci prend une variable en entrée et cette dernière peut être de tout type, on la typera donc Any. Comme la fonction retourne une copie de la variable d’entrée, le type de retour de la fonction sera le même que celui de la variable en entrée (Any).

Le problème est qu’avec Any, on a aucun moyen de vérifier que les types de l’entrée et de la sortie sont les mêmes, et c’est ici que le générique entre en jeu.

def copy_of_1(value: Any) -> Any: # Le Any d'entrée et 
                                  # le Any de sortie 
                                  # ne sont pas forcés 
                                  # d'être du même type
    return deepcopy(value)


T = TypeVar("T")

def copy_of_2(value: T) -> T:     # Les variables en entrée 
                                  # et en sortie sont forcées 
                                  # d'être du même type
    return deepcopy(value)

Le Variadic générique, quant à lui, est plus complexe et permet de gérer des types multi-dimensionnels. 🔎Là encore la mise en œuvre est subtile, je vous invite à aller lire la documentation officielle.

Par contre dans les petits ajouts qui peuvent servir à tout le monde, il y a les types Required et NotRequired dans les TypedDict :

class Shape(TypedDict):
    x: Required[int]
    y: Required[int]
    color: NotRequired[int]

Il y aussi le type Self qui a été ajouté, très pratique pour faire un constructeur :

class Shape:
    @classmethod
    def new(cls, x: int, y: int) -> Self:
        ...

Il y a aussi un type LiteralString ajouté. Il peut être utilisé pour indiquer qu’un paramètre de fonction peut être de n’importe quel type de chaîne littérale (chaîne de caractères écrite en dur dans le code, comme « Hello World ! »).

Ce type est principalement utile pour renforcer la sécurité 💪 car il indique qu’une variable doit être codée en dur. Contrairement à un simple str, ce type garantit donc que la valeur provient directement du code source, sans transformation dynamique. Ainsi, LiteralString établit une distinction importante entre les chaînes définies explicitement dans le code et celles obtenues dynamiquement.

🧐 Si vous êtes du genre à aimer creuser (ou que vous êtes simplement têtu·e comme moi), voici ce que j’ai compris sur le fonctionnement de LiteralString :

Le LiteralString vous permet d’ajouter un peu de sécurité et de rigueur dans votre code sans pour autant avoir un typage trop drastique. Pour mieux visualiser, on peut prendre en exemple une fonction d’affichage des logs.

def log(level: str, message: str):
    if level == "Error":
        print(message)

level = "Error" + "\u200b"  # "Error" avec un caractère invisible (Zero Width Space)
log(level, "Ce message s'affiche !")

Avec cette version de la fonction, on peut voir qu’il y a un problème de sécurité car notre message s’affichera alors que la variable level n’est pas exactement égale à Error. Pas bien grave me direz-vous. Et, en effet, ce n’est pas très important pour une fonction de logs, mais s’il s’agissait de la gestion de vos bases de données …

À l’inverse, on le Literal à l’extrême du typage :

from typing import Literal

ERROR: Literal["Error"] = "Error"

def log(level: Literal["Error"], message: str):
    if level == ERROR:
        print(message)

log(ERROR, "Ce message s'affiche !") # Pas d'alert sur le type

level = "Error" + "\u200b"  # "Erreur" avec un caractère invisible (Zero Width Space)
log(level, "Ce message ne s'affichera pas !") # Une erreur de type sera indiquée

Avec cette version de la fonction, on contrôle strictement le type de la variable level en entrée. Par contre, dès lors que l’on augmente le nombre de types possibles en entrée, la syntaxe devient laborieuse. De même, il est possible de ne pas connaître à l’avance tous les types d’entrée possible (dans le cas de composants extérieurs).

from typing import Literal

INFO: Literal["Info"] = "Info"
WARNING: Literal["Warning"] = "Warning"
ERROR: Literal["Error"] = "Error"

def log(external_component: ???, # Le type n'est pas connu
        level: Literal["Info", "Warning", "Error", ...], # On pourrait avoir beaucoup de types possibles
        message: str):
    if level == ERROR:
        print(message)

Pour remédier à ces deux situations, on a donc trouvé le compromis du LiteralString :

from typing import LiteralString, Literal

INFO: Literal["Info"] = "Info"
WARNING: Literal["Warning"] = "Warning"
ERROR: Literal["Error"] = "Error"

def log(external_component: LiteralString, level: LiteralString, message: str):
    if level == ERROR:
        print(message)

log(INFO, "Ce message ne s'affichera pas !") # Pas d'alert sur le type
log(WARNING, "Ce message ne s'affichera pas !") # Pas d'alert sur le type
log(ERROR, "Ce message s'affiche !") # Pas d'alert sur le type

Il est également possible d’utiliser à la fois le Literal et le LiteralString :

from typing import LiteralString, Literal

INFO: LiteralString = "Info" # On définit INFO avec un type moins précis
WARNING: Literal["Warning"] = "Warning"
ERROR: Literal["Error"] = "Error"

def log(external_component: LiteralString, 
        level: Literal["Warning", "Error"], # Level est soit du type Literal["Warning"] soit du type Literal["Error"]
        message: str):
    if level == ERROR:
        print(message)

log(INFO, "Ce message ne s'affichera pas !") # Alerte sur le type

log(WARNING, "Ce message ne s'affichera pas !") # Pas d'alerte sur le type
log(ERROR, "Ce message s'affiche !") # Pas d'alerte sur le type
log(WARNING+ERROR, "Ce message ne s'affichera pas !") # Pas d'alerte sur le type. Pas très intéressant mais pourquoi pas ...

Grâce aux deux exemples précédents, on peut donc en conclure que LiteralString regroupe tous les Literal[<…>] <…> est une chaîne de caractères. Ainsi, on en déduit que LiteralString est le supertype de tous les types de chaînes littérales.

🚨Donc, tout « sous-type » de LiteralString (Literal[« Error »] ou bien encore Literal[« Warning »]) est compatible avec LiteralString , mais pas l’inverse (se référer à la variable INFO de l’exemple précédent).

🚨De même, le supertype LiteralString est lui-même un str, faisant de str un super supertype.

🚨Finalement, avec la même logique que précédemment, on en déduit bien qu’un str n’est pas compatible avec un LiteralString . On entend par là qu’il est possible d’assigner un LiteralString à un str, mais pas l’inverse.

literal_string: LiteralString
s: str = literal_string                 # OK

literal_string: LiteralString = s       # Erreur : 
                                        # On attendait un
                                        # LiteralString, 
                                        # on a un str
literal_string: LiteralString = "hello" # OK

Une chaîne créée en composant des objets typés LiteralString est, quant-à-elle, acceptable en tant que LiteralString (comme pour les Literal).

literal_string_1: LiteralString = "Hello"
literal_string_2: LiteralString = " World"

composed_string: LiteralString = literal_string_1 + literal_string_2 + " !"  # Toujours un LiteralString

Ce type est utile pour les API sensibles où des chaînes arbitraires générées par l’utilisateur peuvent générer des problèmes.

🔎Pour plus d’exemples, vous pouvez vous référer à la documentation officielle de Python.

Du côté des décorateurs, la version 3.11 ajoute dataclass_transform qui est applicable à une classe, une métaclasse ou un décorateur. Ce décorateur permet de marquer un objet comme offrant un comportement de type dataclass tout en effectuant la vérification des types.

Pour rappel, le décorateur dataclass ajoute des méthodes générées et spéciales à une classe. On aura par exemple des méthodes comme __init__, __repr__ ou encore __eq__.

# Le décorateur create_model est défini par une bibliothèque.
@typing.dataclass_transform()
def create_model(cls: Type[T]) -> Type[T]:
    cls.__init__ = ...
    cls.__eq__ = ...
    cls.__ne__ = ...
    return cls

# Le décorateur create_model peut désormais être utilisé 
# pour créer de nouvelles classes de modèles :
@create_model
class CustomerModel:
    id: int
    name: str

c = CustomerModel(id=327, name="Eric Idle")

Version 3.12

La version 3.12 quant à elle introduit l’utilisation du type dictionnaire TypedDict pour avoir un typage plus précis des arguments (**kwargs).

Avant cet ajout, les **kwargs pouvaient être typés à condition que tous les arguments de mot-clé qu’ils spécifient soient du même type. Or, ce comportement était très limitant. Par exemple, annoter **kwargs avec un type str signifie que le type **kwargs est en fait un dict[str, str] et donc que tous les arguments de mot-clé dans foo sont des chaînes de caractères.

def foo(**kwargs: str) -> None: ...

Malheureusement, il arrive souvent que les arguments de mots-clés véhiculés par **kwargs aient des types différents qui dépendent du nom du mot-clé. Dans ce cas, il n’était pas possible d’annoter le type des **kwargs.

Maintenant, en utilisant TypedDict pour typer les **kwargs, il est possible d’assigner un dictionnaire comme type des **kwargs. Ainsi, les **kwargs peuvent être typés séparément (par clé du dictionnaire).

from typing import TypedDict, Unpack

class Movie(TypedDict):
  name: str
  year: int

def foo(**kwargs: Unpack[Movie]): ...

🔎Pour plus de détails, je vous invite à consulter la documentation officiel de Python.

La version 3.12 offre aussi un nouveau décorateur override qui sera sans doute utile pour une grande majorité. Ce dernier indique qu’une méthode dans une sous-classe est destinée à remplacer une méthode (ou un attribut) dans une classe parente.

✨Cette version apporte également de nouvelles caractéristiques syntaxiques pour créer des classes génériques et des fonctions de façon explicite et compacte.

def max[T](args: Iterable[T]) -> T:
    ...

class list[T]:
    def __getitem__(self, index: int, /) -> T:
        ...

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

De plus, une nouvelle façon de déclarer des alias de type est introduite. Comme présenté sur l’exemple suivant, l’instruction type est utilisée, ce qui crée une instance de TypeAliasType et rend la déclaration explicite.

type Point = tuple[float, float]

Version 3.13

Avec la version 3.13, il est maintenant possible de définir une valeur par défaut pour les paramètres de type (TypeVar, ParamSpec, et TypeVarTuple).

T = TypeVar("T", default=int) # Si aucun type n'est spécifié, 
                              # T sera de type int

@dataclass
class Box(Generic[T]):
    value: T | None = None

reveal_type(Box())                      # Le type est Box[int]
reveal_type(Box(value="Hello World!"))  # Le type est Box[str]

Il également possible, depuis cette version, de marquer une classe ou une fonction comme dépréciée à l’aide du nouveau décorateur deprecated. Ainsi, on peut informer les développeurs lorsqu’ils utilisent ces classes et fonctions pour qu’ils mettent en place les migrations nécessaires.

Autre petit ajout très utile, le qualificatif ReadOnly pour le type TypedDict qui permet de définir certaines clés comme étant en lecture seule. L’utilisation correcte de ces clés en lecture seule est destinée à être appliquée uniquement par les vérificateurs de type statique et non pas par Python lui-même au moment de l’exécution.

Finalement, la version 3.13 revient sur son ajout de TypeGuard dans la version 3.10. Cette version propose une alternative plus intuitive à TypeGuard : TypeIs. Cette nouvelle forme permet l’annotation de fonctions pouvant être utilisées pour affiner le type d’une valeur.

from typing import assert_type, final, TypeIs

class Parent: pass
class Child(Parent): pass
@final
class Unrelated: pass

def is_parent(val: object) -> TypeIs[Parent]:
    return isinstance(val, Parent)

def run(arg: Child | Unrelated):
    if is_parent(arg):
        # Le type de ``arg`` est réduit à l'intersection entre 
        # ``Parent`` et ``Child``, 
        # ce qui équivaut à ``Child``.
        assert_type(arg, Child)
    else:
        # Le type de ``arg`` est réduit pour exclure ``Parent``, 
        # de sorte qu'il ne reste que ``Unrelated``.
        assert_type(arg, Unrelated)

Contrairement à la forme spéciale TypeGuard existante, TypeIs peut affiner le type dans les branches if et else d’une condition. Cependant, TypeIs ne peut pas être utilisé lorsque les types d’entrée et de sortie sont incompatibles (par exemple, list[object] vers list[int]), ou lorsque la fonction ne renvoie pas True pour toutes les instances du type rétréci.

🔎Pour plus de précisions, je vous renvoie vers la documentation officielle.

Conclusion

📈On constate que le typage en Python est en pleine évolution, avec chaque version apportant son lot d’améliorations pour le rendre plus expressif, plus robuste et plus facile à utiliser.

Les ajouts récents, comme Self, LiteralString, TypedDict ou encore override, montrent une volonté de rendre le typage plus intuitif et utile dans des scénarios concrets.

Avec la version 3.13, Python continue sur cette lancée en offrant des outils plus flexibles et en réajustant certaines décisions, comme l’alternative TypeIs pour TypeGuard.

👉En résumé, le typage en Python présente des avantages (et aussi, parfois, des inconvénients) qui méritent d’être pris en compte dans vos développements.

➕ D’un côté, il apporte une meilleure sécurité en réduisant le risque de bugs et les attaques. En imposant des types clairs, il permet également une meilleure lisibilité du code, notamment lorsque les structures et les workflows deviennent plus complexes. Cela facilite non seulement la maintenance, mais aussi la reprise du code par d’autres développeurs, rendant ainsi la collaboration plus fluide.

➖ Cependant, le typage en Python présente aussi quelques limites. Certains types ou fonctionnalités, comme TypeGuard, peuvent être difficiles à prendre en main. De plus, certains types, tels que LiteralString, n’apportent pas toujours une réelle valeur ajoutée au regard de leur complexité d’utilisation. Enfin, l’ajout de types peut parfois alourdir visuellement le code, ce qui peut nuire à sa lisibilité.

Pour ma part, je pense qu’il est essentiel de prêter attention au typage. Il contribue grandement à la compréhension et à la maintenance du code, notamment lorsqu’il s’agit de reprendre le travail de quelqu’un d’autre. À mon sens, il est au minimum nécessaire de typer les prototypes de méthodes, en précisant clairement les types des entrées et des sorties. Au final, le plus important reste de discuter des normes de typage avec son équipe afin d’adopter une approche cohérente et adaptée aux besoins du projet.

Quoi qu’il en soit, le langage Python gagne en maturité, mais le typage demeure un terrain d’innovation. Il nous tarde de découvrir ses prochaines évolutions !

P.S. : Pour en apprendre plus sur les évolutions de Python (hors typage), je vous invite à consulter notre article intitulé « Découvrez les évolutions majeures de Python de 3.9 à 3.13 ».


Auteur : Mathilde Pommier

Leave a Reply