48  Type hints et mypy

Python est dynamiquement typé : on ne déclare pas les types. C’est pratique pour prototyper, mais dangereux à grande échelle — un bug de type se découvre seulement à l’exécution. Depuis Python 3.5, on peut ajouter des annotations de types (type hints) qui, sans ralentir le code, permettent à des outils comme mypy de détecter les erreurs avant exécution. C’est une compétence très valorisée en entreprise.

48.1 Pourquoi annoter ?

Sans annotations :

def calculer_total(prix, quantite, tva):
    return prix * quantite * (1 + tva)

# Que veut-on passer ? Des nombres ? Des listes ?
# Aucun moyen de savoir sans lire le corps de la fonction.

Avec annotations :

def calculer_total(prix: float, quantite: int, tva: float) -> float:
    """Calcule le total TTC."""
    return prix * quantite * (1 + tva)

Maintenant :

  • La signature est auto-documentée : on voit les types attendus et le retour.
  • L’éditeur (VS Code, PyCharm) peut vérifier pendant que vous tapez.
  • mypy peut analyser tout le projet avant exécution.
Important : Python ignore les annotations à l’exécution
def additionner(a: int, b: int) -> int:
    return a + b

# Python NE VÉRIFIE PAS que a et b sont des int
print(additionner("Hello ", "World"))      # fonctionne !
print(additionner(3.14, 2.5))              # fonctionne aussi
Hello World
5.640000000000001

Les annotations sont pour les humains et les outils, pas pour l’interpréteur. Pour la validation à l’exécution, il faut soit isinstance(), soit des bibliothèques comme Pydantic.

48.2 Syntaxe de base

Variables

age: int = 30
nom: str = "Alice"
pi: float = 3.14
est_actif: bool = True
notes: list = [12, 15, 14]

L’annotation vient après le nom, séparée par :.

Fonctions : paramètres et retour

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


def age_est_majeur(age: int) -> bool:
    return age >= 18


def afficher(message: str) -> None:         # ne renvoie rien
    print(message)

-> None signifie que la fonction ne renvoie rien (ou None implicite).

Paramètres avec valeur par défaut

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

48.3 Les types de base

Types primitifs

Type Python Annotation
Entier int
Flottant float
Chaîne str
Booléen bool
Octets bytes
Rien None

Collections (avant Python 3.9)

Pour les collections typées, on devait importer depuis typing :

from typing import List, Dict, Tuple, Set

notes: List[int] = [12, 15, 14]
ages: Dict[str, int] = {"Alice": 30}
point: Tuple[float, float] = (3.0, 4.0)
valeurs: Set[str] = {"a", "b", "c"}

Collections (Python 3.9+) — syntaxe moderne

Depuis Python 3.9, on peut utiliser directement les types builtin avec des crochets :

notes: list[int] = [12, 15, 14]
ages: dict[str, int] = {"Alice": 30, "Bob": 25}
point: tuple[float, float] = (3.0, 4.0)
valeurs: set[str] = {"a", "b", "c"}

# Plus besoin d'importer !

Adoptez cette syntaxe — c’est la norme actuelle.

Tuple vs liste pour les paramètres
  • Liste : list[int] — taille variable, tous du même type.
  • Tuple fixe : tuple[int, str, float] — 3 éléments, types précis.
  • Tuple variable : tuple[int, ...] — nombre variable d’int.
coordonnee: tuple[float, float] = (3.0, 4.0)    # exactement 2 float
chemin: tuple[int, ...] = (1, 2, 3, 4, 5)       # nombre variable d'int

48.4 Types optionnels et unions

Optional : peut être None

Souvent, une valeur peut être None. On l’exprime avec Optional ou | None (Python 3.10+).

# Ancienne syntaxe
from typing import Optional

def chercher(nom: str) -> Optional[str]:
    # peut renvoyer str OU None
    if nom == "Alice":
        return "trouvé"
    return None


# Python 3.10+ : syntaxe avec |
def chercher_moderne(nom: str) -> str | None:
    if nom == "Alice":
        return "trouvé"
    return None

Les deux sont équivalents. Préférez str | None pour du code moderne.

Union : plusieurs types possibles

# Ancienne syntaxe
from typing import Union

def convertir(valeur: Union[int, str]) -> int:
    return int(valeur)


# Python 3.10+
def convertir_moderne(valeur: int | str) -> int:
    return int(valeur)

print(convertir_moderne(42))
print(convertir_moderne("100"))
42
100

La syntaxe | est lisible et identique à celle qu’on utilise en pattern matching.

48.5 Any : échappatoire

Any désactive la vérification de type. À utiliser avec parcimonie — signifie « je ne sais pas / tout va ».

from typing import Any

def stocker_n_importe_quoi(x: Any) -> None:
    """Accepte n'importe quel type."""
    print(f"Stocké : {x} (type {type(x).__name__})")


stocker_n_importe_quoi(42)
stocker_n_importe_quoi("Hello")
stocker_n_importe_quoi([1, 2, 3])
Stocké : 42 (type int)
Stocké : Hello (type str)
Stocké : [1, 2, 3] (type list)

Any trahit l’intérêt du typage — ne l’utilisez que pour du code qui accepte vraiment tout type (décorateurs génériques, APIs très flexibles).

48.6 Alias de types

Pour éviter de répéter une annotation complexe, on peut créer un alias.

# Ancienne syntaxe (Python < 3.12)
from typing import Dict, List

Vecteur = list[float]
Matrice = list[list[float]]
Base = dict[str, int]


def produit_scalaire(v1: Vecteur, v2: Vecteur) -> float:
    return sum(a * b for a, b in zip(v1, v2))


print(produit_scalaire([1.0, 2.0, 3.0], [4.0, 5.0, 6.0]))
32.0
Python 3.12+ : type keyword

Python 3.12 a introduit un mot-clé type pour définir des alias explicitement :

type Vecteur = list[float]     # Python 3.12+

Plus propre et plus explicite. Pour le TOSA (Python 3.10), utilisez la forme simple Vecteur = list[float].

48.7 Callable : fonctions en paramètre

Pour annoter un paramètre qui est une fonction, on utilise Callable.

from typing import Callable


def appliquer(fonction: Callable[[int], int], valeur: int) -> int:
    """Applique `fonction` à `valeur`."""
    return fonction(valeur)


def double(x: int) -> int:
    return x * 2


print(appliquer(double, 5))
print(appliquer(lambda x: x + 100, 5))
10
105

Syntaxe : Callable[[types_arguments], type_retour].

  • Callable[[int], int] : fonction qui prend un int et renvoie un int.
  • Callable[[str, int], None] : prend (str, int), ne renvoie rien.
  • Callable[..., bool] : n’importe quels arguments, renvoie bool.

48.8 Generic types avec TypeVar

Pour des fonctions qui acceptent n’importe quel type mais préservent ce type, on utilise TypeVar.

from typing import TypeVar

T = TypeVar("T")

def premier(liste: list[T]) -> T:
    """Renvoie le premier élément, du même type que la liste."""
    return liste[0]


# Le type du retour suit le type de la liste
x: int = premier([1, 2, 3])        # x est bien int
y: str = premier(["a", "b", "c"])  # y est bien str

print(x, y)
1 a

Sans TypeVar, on aurait utilisé Any — perdant l’info de type. Avec, l’outil sait que premier([1, 2]) renvoie un int.

48.9 Annotations de classes

Attributs d’instance

class Personne:
    nom: str
    age: int
    email: str | None = None

    def __init__(self, nom: str, age: int) -> None:
        self.nom = nom
        self.age = age

Méthodes

class CompteBancaire:
    def __init__(self, titulaire: str, solde: float = 0.0) -> None:
        self.titulaire = titulaire
        self.solde = solde

    def deposer(self, montant: float) -> None:
        if montant <= 0:
            raise ValueError("Montant invalide")
        self.solde += montant

    def consulter(self) -> float:
        return self.solde

Auto-référence : le type de la classe elle-même

Parfois une méthode renvoie une instance de sa propre classe — souci : la classe n’est pas encore définie au moment où on l’annote.

Python 3.11+ : Self (depuis typing).

from typing import Self

class Compte:
    def doubler(self) -> Self:
        nouveau = Compte()
        nouveau.solde = self.solde * 2
        return nouveau

Avant 3.11 : chaîne de caractères (forward reference).

class Compte:
    def __init__(self, solde: float = 0) -> None:
        self.solde = solde

    def doubler(self) -> "Compte":          # ← chaîne
        nouveau = Compte()
        nouveau.solde = self.solde * 2
        return nouveau


c = Compte(100)
print(c.doubler().solde)
200

48.10 mypy — la vérification statique

mypy analyse votre code et détecte les erreurs de type sans l’exécuter.

Installation

pip install mypy

Utilisation

# Vérifier un fichier
mypy mon_script.py

# Vérifier un dossier
mypy src/

Exemple

# fichier : calcul.py
def additionner(a: int, b: int) -> int:
    return a + b

# Usage incorrect
resultat = additionner("Hello", "World")
$ mypy calcul.py
calcul.py:5: error: Argument 1 to "additionner" has incompatible type "str"; expected "int"
calcul.py:5: error: Argument 2 to "additionner" has incompatible type "str"; expected "int"

mypy repère le bug avant que l’utilisateur ne lance le programme.

Options utiles

mypy --strict           # mode le plus strict (recommandé pour projets pros)
mypy --ignore-missing-imports   # ignore les modules non annotés
mypy --show-error-codes         # affiche le code de chaque erreur

Le mode --strict active toutes les vérifications :

  • Tous les arguments et retours doivent être annotés.
  • Pas d’Any implicite.
  • Les fonctions sans annotation sont signalées.

48.11 Configurer mypy

Dans un fichier mypy.ini ou pyproject.toml :

# mypy.ini
[mypy]
python_version = 3.10
strict = True
warn_unused_ignores = True
disallow_untyped_defs = True

Ou dans pyproject.toml :

[tool.mypy]
python_version = "3.10"
strict = true

48.12 Types avancés pour le TOSA Expert

Literal — valeur constante

Pour restreindre à certaines valeurs précises :

from typing import Literal

def set_niveau(niveau: Literal["debug", "info", "warning", "error"]) -> None:
    print(f"Niveau : {niveau}")


set_niveau("info")         # OK
# set_niveau("verbose")    # mypy : erreur !
Niveau : info

TypedDict — dicts typés

Pour un dict avec des clés connues et typées :

from typing import TypedDict


class Utilisateur(TypedDict):
    nom: str
    age: int
    admin: bool


# Usage : comme un dict normal, mais mypy vérifie
alice: Utilisateur = {"nom": "Alice", "age": 30, "admin": True}
print(alice["nom"])
Alice

TypedDict ne change pas le comportement à l’exécution, mais mypy vérifie la structure.

NamedTuple — tuples avec noms typés

from typing import NamedTuple


class Point(NamedTuple):
    x: float
    y: float


p = Point(3.0, 4.0)
print(p.x, p.y)       # accès par nom
print(p[0], p[1])     # accès par indice (reste un tuple)
3.0 4.0
3.0 4.0

Équivalent léger et immuable d’un @dataclass.

Protocol — typage structurel (Python 3.8+)

Protocol permet de typer par ce qu’un objet peut faire, pas par sa hiérarchie de classes. C’est le « duck typing » statique.

from typing import Protocol


class AvecNom(Protocol):
    nom: str


def afficher_nom(obj: AvecNom) -> None:
    print(obj.nom)


# N'importe quelle classe avec un attribut .nom de type str fait l'affaire
class Personne:
    def __init__(self, nom: str) -> None:
        self.nom = nom


class Produit:
    def __init__(self, nom: str) -> None:
        self.nom = nom


afficher_nom(Personne("Alice"))
afficher_nom(Produit("Pain"))
Alice
Pain

Pas besoin d’héritage. mypy vérifie juste que l’objet a un nom: str.

48.13 Quand annoter (et quand pas)

Recommandations pratiques

Annotez systématiquement :

  • Les signatures publiques : arguments et retours de fonctions/méthodes exposées.
  • Les attributs de classe qui ne sont pas initialisés avec une valeur typée.

Pas besoin d’annoter :

  • Les variables locales évidentes (x = 10, nom = "Alice").
  • Du code de script jetable ou prototype.

Cas délicat :

  • Les *args et **kwargs : *args: int, **kwargs: str.
  • Les fonctions qui renvoient des types complexes : prenez le temps, ça documente.

48.14 Exemple complet

from dataclasses import dataclass


@dataclass
class Produit:
    nom: str
    prix: float
    quantite: int = 1

    def valeur_totale(self) -> float:
        return self.prix * self.quantite


def panier_total(produits: list[Produit], tva: float = 0.20) -> float:
    """Calcule le total TTC d'un panier."""
    total_ht = sum(p.valeur_totale() for p in produits)
    return round(total_ht * (1 + tva), 2)


def produit_le_plus_cher(produits: list[Produit]) -> Produit | None:
    """Renvoie le produit le plus cher, ou None si panier vide."""
    if not produits:
        return None
    return max(produits, key=lambda p: p.prix)


# Utilisation
panier: list[Produit] = [
    Produit("Pain", 1.20, 2),
    Produit("Lait", 0.95, 3),
    Produit("Fromage", 4.50),
]

print(f"Total TTC : {panier_total(panier):.2f} €")
print(f"Plus cher : {produit_le_plus_cher(panier)}")
Total TTC : 11.70 €
Plus cher : Produit(nom='Fromage', prix=4.5, quantite=1)

Ce code est entièrement typé. mypy peut le vérifier sans signaler d’erreur. Un nouvel arrivant dans l’équipe comprend les signatures sans lire les corps.


🧩 Quiz 8.1 — Type hints

Question 1

Python vérifie-t-il les annotations à l’exécution ?

  1. Oui, toujours
  2. Non — elles sont pour les outils et la doc uniquement
  3. Seulement si on l’active
  4. Oui, mais seulement en mode debug

b) Non — Python ignore les annotations au runtime. Pour vérifier, on utilise mypy (statique) ou des bibliothèques comme Pydantic (runtime).

Question 2

Quelle annotation utiliser pour une fonction qui ne renvoie rien ?

  1. -> void
  2. -> null
  3. -> None
  4. Rien

c) -> None — c’est explicite et détectable par mypy. Omettre l’annotation est aussi légal mais moins informatif.

Question 3

Comment annote-t-on « liste d’entiers » en Python 3.10 ?

  1. List[int]
  2. list[int]
  3. list<int>
  4. [int]

b) list[int] — syntaxe moderne (Python 3.9+) avec les builtins. List[int] depuis typing fonctionne encore mais est déprécié.

Question 4

Quelle est l’équivalent moderne de Optional[str] en Python 3.10 ?

  1. str | None
  2. str | Null
  3. Maybe[str]
  4. str?

a) str | NoneOptional[X] est strictement équivalent à X | None. La forme | est préférée en Python 3.10+.

Question 5

À quoi sert Any ?

  1. À indiquer qu’on accepte tout type (désactive la vérif)
  2. À indiquer un booléen
  3. À indiquer une erreur
  4. À signaler une variable obligatoire

a)Any est un échappatoire au typage. À utiliser avec parcimonie : trop d’Any équivaut à ne pas typer du tout.

Question 6

Quelle syntaxe annote « fonction prenant un int et renvoyant un str » ?

  1. Function[int, str]
  2. Callable[[int], str]
  3. int -> str
  4. Callable(int, str)

b) Callable[[int], str] — arguments dans une liste, type de retour après.

from typing import Callable

def appliquer(f: Callable[[int], str], x: int) -> str:
    return f(x)

Question 7

Que fait mypy ?

  1. Exécute le code avec vérification de types
  2. Analyse le code statiquement pour détecter les erreurs de type avant exécution
  3. Formate le code
  4. Ajoute automatiquement des annotations

b) — analyse statique (sans exécution). Détecte les incohérences de type à la lecture du code.

Question 8

Pour annoter un dict {"nom": "Alice", "age": 30} avec structure fixe :

  1. dict[str, str]
  2. dict[str, Any]
  3. Un TypedDict
  4. NamedTuple

c) TypedDict — permet de typer chaque clé différemment (nom: str, age: int). dict[str, Any] perd l’info. Un @dataclass est souvent encore mieux selon le cas.


✏️ Exercice 8.1 — Annoter une fonction simple

Ajoutez les annotations à cette fonction.

def moyenne(notes):
    if not notes:
        return None
    return sum(notes) / len(notes)
def moyenne(notes: list[float]) -> float | None:
    if not notes:
        return None
    return sum(notes) / len(notes)


print(moyenne([12, 15, 14]))
print(moyenne([]))
13.666666666666666
None

Alternatives valables

# Accepter aussi des int (les int sont compatibles avec float)
def moyenne(notes: list[float]) -> float | None: ...

# Plus strict : seulement int
def moyenne(notes: list[int]) -> float | None: ...

# Accepter n'importe quel nombre
from typing import Union
def moyenne(notes: list[int | float]) -> float | None: ...

En pratique : list[float] accepte list[int] pour mypy (covariance numérique).


✏️ Exercice 8.2 — Typer une classe

Annotez entièrement cette classe.

class CompteBancaire:
    def __init__(self, titulaire, solde=0):
        self.titulaire = titulaire
        self.solde = solde
        self.historique = []

    def deposer(self, montant):
        if montant <= 0:
            raise ValueError("Montant invalide")
        self.solde += montant
        self.historique.append(("depot", montant))

    def resume(self):
        return {
            "titulaire": self.titulaire,
            "solde": self.solde,
            "operations": len(self.historique),
        }
class CompteBancaire:
    titulaire: str
    solde: float
    historique: list[tuple[str, float]]

    def __init__(self, titulaire: str, solde: float = 0) -> None:
        self.titulaire = titulaire
        self.solde = solde
        self.historique = []

    def deposer(self, montant: float) -> None:
        if montant <= 0:
            raise ValueError("Montant invalide")
        self.solde += montant
        self.historique.append(("depot", montant))

    def resume(self) -> dict[str, str | float | int]:
        return {
            "titulaire": self.titulaire,
            "solde": self.solde,
            "operations": len(self.historique),
        }


# Test
c = CompteBancaire("Alice", 100)
c.deposer(50)
print(c.resume())
{'titulaire': 'Alice', 'solde': 150, 'operations': 1}

Commentaires

  • list[tuple[str, float]] : liste de tuples (type_operation, montant).
  • dict[str, str | float | int] : les valeurs sont hétérogènes. Dans un vrai projet, un TypedDict serait plus précis :
from typing import TypedDict


class ResumeCompte(TypedDict):
    titulaire: str
    solde: float
    operations: int


class CompteBancaire:
    def __init__(self, titulaire: str, solde: float = 0) -> None:
        self.titulaire = titulaire
        self.solde = solde
        self.historique: list[tuple[str, float]] = []

    def resume(self) -> ResumeCompte:
        return {
            "titulaire": self.titulaire,
            "solde": self.solde,
            "operations": len(self.historique),
        }


c = CompteBancaire("Alice", 100)
print(c.resume())
{'titulaire': 'Alice', 'solde': 100, 'operations': 0}

✏️ Exercice 8.3 — Fonction générique avec TypeVar

Écrivez une fonction générique dernier(sequence) qui renvoie le dernier élément d’une séquence, du même type que les éléments.

from typing import TypeVar

T = TypeVar("T")

def dernier(sequence: ...) -> ...:
    ...

# Tests (mypy doit savoir le type retourné)
x: int = dernier([1, 2, 3])         # 3
y: str = dernier(["a", "b", "c"])   # 'c'
from typing import TypeVar

T = TypeVar("T")


def dernier(sequence: list[T]) -> T:
    if not sequence:
        raise ValueError("séquence vide")
    return sequence[-1]


# Tests
x: int = dernier([1, 2, 3])
print(x, type(x))

y: str = dernier(["a", "b", "c"])
print(y, type(y))

z: float = dernier([1.5, 2.5, 3.5])
print(z, type(z))
3 <class 'int'>
c <class 'str'>
3.5 <class 'float'>

Bonus : fonctionne aussi sur les tuples

Avec une protocole Sequence plus général :

from typing import TypeVar
from collections.abc import Sequence

T = TypeVar("T")

def dernier(sequence: Sequence[T]) -> T:
    if not sequence:
        raise ValueError("séquence vide")
    return sequence[-1]


print(dernier([1, 2, 3]))
print(dernier((10, 20, 30)))
print(dernier("hello"))
3
30
o

Sequence accepte list, tuple, str — n’importe quel objet indexable de taille finie.


✏️ Exercice 8.4 — Détecter les erreurs de type

Pour chaque extrait, identifiez l’erreur que mypy signalerait.

# A
def doubler(x: int) -> int:
    return x * 2

doubler("hello")

# B
def premier(liste: list[int]) -> int:
    return liste[0]

premier([])   # quelle erreur pourrait remonter à l'exécution, mais mypy ?

# C
def saluer(nom: str | None) -> str:
    return f"Bonjour {nom.upper()}"

# D
from typing import Any
def charger_config(path: str) -> Any:
    return {}  # renvoie un dict, mais annoté Any

data = charger_config("...")
print(data.xyz)    # erreur ou pas pour mypy ?

A — Erreur : doubler("hello") passe un str alors qu’on attend int. mypy signale une incompatibilité de type.

B — Pas d’erreur mypy : l’appel premier([]) respecte la signature (list[int]). Mais à l’exécution, liste[0] lève IndexError. mypy ne vérifie pas la longueur des séquences.

Ce cas illustre : mypy ne garantit pas l’absence de bugs runtime, seulement la cohérence de types.

C — Erreur mypy : nom peut être None, sur lequel .upper() ne fonctionne pas. Correction :

def saluer(nom: str | None) -> str:
    if nom is None:
        return "Bonjour inconnu"
    return f"Bonjour {nom.upper()}"


print(saluer("Alice"))
print(saluer(None))
Bonjour ALICE
Bonjour inconnu

Une fois le if nom is None fait, mypy comprend (par narrowing) qu’en dessous nom est forcément str.

D — Pas d’erreur mypy : Any désactive la vérification. data.xyz passe — Any autorise tout.

Leçon : abuser d’Any fait perdre les garanties du typage.


À retenir

Points clés du chapitre
  1. Type hints = annotations de type : x: int, def f() -> bool:. Python les ignore au runtime.
  2. Syntaxe moderne (3.9+) : list[int], dict[str, int], tuple[float, float].
  3. Union : str | int (Python 3.10+), optionnel : str | None.
  4. Callable[[int], str] pour typer une fonction en paramètre.
  5. TypeVar pour des fonctions génériques qui préservent le type.
  6. Literal["a", "b"], TypedDict, NamedTuple, Protocol pour des cas avancés.
  7. Any désactive la vérif : à utiliser avec parcimonie.
  8. mypy vérifie statiquement les types. Run mypy monscript.py.
  9. Auto-référence : "MaClasse" ou Self (3.11+).
  10. Annotez les signatures publiques, pas forcément chaque variable locale.

← Chapitre précédent : Tests unitairesTP récapitulatif →