Typing avancé (Protocol/Generic/TypedDict/Literal)
TL;DR — Le typing Python avancé, c'est l'art de décrire la forme et le contrat de tes données et de tes objets sans alourdir le runtime.
Literalferme l'ensemble des valeurs autorisées (unEnumléger qui vit dans les types),TypedDicttype les dicts JSON sans payer le coût d'une classe,Protocolexprime le duck typing de manière vérifiable (structural typing, à la TypeScriptinterface), etGeneric/TypeVarrend ton code réutilisable et type-safe sur plusieurs types. Tout ça est effacé à l'exécution (typing≈ documentation exécutable vérifiée parmypy/pyright), sauf si tu demandes explicitement le contraire. Pour qui sert un agent LLM, ces outils sont ce qui transforme une réponsedict[str, Any]opaque en un flux de tokens, de tool-uses et de sorties structurées dont le type checker garantit la cohérence.
Tu viens de PHP/TS avec 7 ans de métier : tu connais déjà les interface, les génériques TS, les unions discriminées. Bonne nouvelle — Python a tout ça, mais avec une philosophie différente : le système de types est gradual et structurel, optionnel à l'exécution, et c'est toi qui décides où poser le curseur entre rigueur et vitesse. Cette leçon te fait passer de « j'annote def f(x: int) -> str » à « je modélise un protocole d'agent générique vérifié statiquement ».
🧠 Mental model
Pense au système de types Python comme à un plan d'architecte annoté que personne n'est obligé de suivre sur le chantier.
mypy/pyrightsont les inspecteurs qui lisent le plan et refusent le permis de construire si les murs ne s'alignent pas.- Le runtime CPython est l'ouvrier : il ne lit pas le plan, il pose les briques. Une annotation fausse ne fait pas planter le programme à l'exécution (sauf cas explicites comme Pydantic).
Protocol= « je me fiche du nom de la classe, je veux juste qu'elle ait une porte ici et une fenêtre là » (typage structurel).Generic/TypeVar= un plan paramétrable : « un entrepôt, mais dis-moi quel type de marchandise il stocke et je garantis qu'on n'y mettra pas autre chose ».TypedDict= un formulaire administratif : un dict dont chaque champ a un type connu, mais sans la cérémonie d'une classe.Literal= une case à cocher avec un nombre fini d'options.
VÉRIFIÉ STATIQUEMENT EFFACÉ AU RUNTIME
(mypy / pyright / IDE) (CPython n'en sait rien*)
┌──────────────────────────────────────────┐ ┌──────────────────────────────┐
│ Literal["opus","sonnet"] │ │ x = "opus" # juste un str │
│ TypedDict {role: str, content: str} │ │ d = {"role": "user", ...} │
│ Protocol (a une méthode .stream()) │ │ obj # juste un objet │
│ Generic[T] (Repository[User]) │ │ Repository() # T disparaît │
└──────────────────────────────────────────┘ └──────────────────────────────┘
│ ▲
└─── pas de coût d'exécution ──────────┘
* sauf: Pydantic valide au runtime, et get_type_hints() peut lire les annotations.La règle d'or du senior Python : le type checker est un test unitaire gratuit qui tourne à chaque frappe. Si tu peux exprimer une invariante dans le système de types plutôt que dans un assert ou un test, fais-le — c'est vérifié plus tôt, partout, et sans exécuter le code.
1. Literal — fermer l'univers des valeurs
En TS tu écris type Model = "opus" | "sonnet". En Python 3.12 :
from typing import Literal
Effort = Literal["low", "medium", "high", "max"]
def configure(effort: Effort) -> None:
...
configure("high") # ✅
configure("ultra") # ❌ mypy: Argument 1 has incompatible type "Literal['ultra']"Literal brille pour les unions discriminées (tagged unions). C'est le pattern qui remplace l'isinstance-soup et que tu vas voir partout dans le SDK Anthropic (les content blocks ont un champ type).
La bonne façon — union discriminée + narrowing exhaustif
from __future__ import annotations
from dataclasses import dataclass
from typing import Literal, assert_never
@dataclass(frozen=True, slots=True)
class TextBlock:
type: Literal["text"]
text: str
@dataclass(frozen=True, slots=True)
class ToolUseBlock:
type: Literal["tool_use"]
name: str
input: dict[str, object]
Block = TextBlock | ToolUseBlock # syntaxe 3.10+, pas besoin de typing.Union
def render(block: Block) -> str:
match block.type:
case "text":
return block.text # mypy sait que block est TextBlock ici
case "tool_use":
return f"<call {block.name}>"
case _:
assert_never(block) # si un jour on ajoute un block, mypy hurle ICIassert_never est la pièce maîtresse : c'est le contrôle d'exhaustivité. Le jour où tu ajoutes ThinkingBlock à l'union sans gérer le cas, mypy échoue à la compilation au lieu de planter en prod. C'est l'équivalent du default: assertNever(x) que tu faisais en TS.
La façon naïve (à éviter)
def render_bad(block: dict) -> str: # dict opaque, zéro garantie
if block["type"] == "text":
return block["text"]
elif block["type"] == "tool_use":
return f"<call {block['name']}>"
return "" # cas silencieux, bug qui dortIci le type checker ne peut rien faire pour toi : block["type"] est Any, block["text"] peut lever KeyError, et le return "" final masque un cas non géré. C'est exactement le genre de code qui survit aux tests et meurt en prod sur un nouveau type de bloc.
2. TypedDict — typer le JSON sans classe
Tu manipules des payloads JSON (API, configs, messages LLM). Tu pourrais tout passer en Pydantic, mais parfois tu veux juste annoter un dict que tu ne contrôles pas, sans coût de validation ni d'instanciation.
from typing import TypedDict, NotRequired, Literal
class Message(TypedDict):
role: Literal["user", "assistant"]
content: str
class RequestPayload(TypedDict):
model: str
max_tokens: int
messages: list[Message]
system: NotRequired[str] # champ optionnel (3.11+)
payload: RequestPayload = {
"model": "claude-opus-4-8",
"max_tokens": 1024,
"messages": [{"role": "user", "content": "Salut"}],
}
# payload["system"] absent → OK car NotRequiredPoints clés que les devs ratent :
TypedDictn'existe qu'à la vérification.isinstance(payload, RequestPayload)lèveTypeError— c'est undictà l'exécution, point.- Pas de valeurs par défaut, pas de méthodes. Si tu veux de la logique →
dataclassou Pydantic. NotRequired/Requiredgèrent l'optionalité champ par champ ;total=Falserend tout optionnel.- Utilise
TypedDictpour décrire des frontières (ce qui entre/sort en JSON brut), Pydantic dès que tu veux valider (l'utilisateur ne suit pas ton plan).
TypedDict vs Pydantic, le verdict senior :
| Besoin | Outil |
|---|---|
| Annoter un dict déjà construit, zéro coût runtime | TypedDict |
| Valider/coercer une entrée non fiable (API, user, LLM) | Pydantic BaseModel |
| Sérialiser avec des règles (alias, exclusion) | Pydantic |
| Config interne immuable, comportement attaché | dataclass(frozen=True, slots=True) |
3. Protocol — le duck typing vérifiable
C'est le concept qui va te parler venant de TS : Protocol = interface structurelle. Une classe satisfait un Protocol si elle a la bonne forme, sans implements, sans héritage, sans rien déclarer.
from typing import Protocol, runtime_checkable
class SupportsStream(Protocol):
def stream(self, prompt: str) -> "Iterator[str]": ...
# AUCUN héritage. Cette classe satisfait SupportsStream par sa seule forme.
class EchoModel:
def stream(self, prompt: str):
for word in prompt.split():
yield word
def consume(model: SupportsStream) -> str:
return " ".join(model.stream("a b c"))
consume(EchoModel()) # ✅ mypy l'accepte : structural typingPourquoi c'est supérieur à l'héritage nominal (ABC) :
- Découplage total : tes objets n'ont pas besoin d'importer ni de connaître le
Protocol. Tu peux typer du code tiers que tu ne possèdes pas. - Testabilité : un fake/mock satisfait le protocole dès qu'il a les bonnes méthodes — pas besoin de sous-classer.
- Inversion de dépendance propre : la couche haute définit le
Protocoldont elle a besoin, les couches basses le satisfont implicitement.
@runtime_checkable et son piège
@runtime_checkable
class SupportsClose(Protocol):
def close(self) -> None: ...
isinstance(open("x"), SupportsClose) # True — mais NE vérifie QUE l'existence
# de .close, PAS sa signature !@runtime_checkable permet isinstance, mais il ne contrôle que la présence des attributs, pas leurs signatures. Ne t'en sers pas comme d'une garantie de type forte — c'est un check de surface.
La façon naïve (à éviter)
# Forcer tout le monde à hériter d'une base concrète : couplage inutile
class BaseModel:
def stream(self, prompt): raise NotImplementedError
class EchoModel(BaseModel): # obligé d'hériter, obligé d'importer BaseModel
def stream(self, prompt): ...L'héritage te force à un couplage et à un ordre d'import que Protocol t'épargne. Réserve les ABC aux cas où tu veux fournir du comportement partagé, pas juste un contrat.
4. Generic / TypeVar — réutilisable ET type-safe
Le pattern repository typé, que tu vas écrire dix fois dans ta carrière. Python 3.12 introduit la syntaxe native des génériques (PEP 695) — beaucoup plus propre que l'ancien TypeVar explicite.
# Python 3.12 — syntaxe PEP 695, pas de TypeVar à déclarer
from typing import Protocol
class Entity(Protocol):
id: int
class Repository[T: Entity]: # T borné : doit avoir un .id
def __init__(self) -> None:
self._store: dict[int, T] = {}
def add(self, item: T) -> None:
self._store[item.id] = item
def get(self, id: int) -> T | None:
return self._store.get(id)
def all(self) -> list[T]:
return list(self._store.values())Utilisation — le type est propagé, pas perdu :
from dataclasses import dataclass
@dataclass
class User:
id: int
name: str
repo: Repository[User] = Repository()
repo.add(User(1, "Achraf"))
u = repo.get(1) # mypy: u est «User | None», pas «Any»
if u:
print(u.name) # autocomplétion + vérif statique
repo.add("oops") # ❌ mypy: expected "User"Variance, en une phrase utile
Tborné parEntity([T: Entity]) = borne supérieure : T peut être Entity ou n'importe quelle sous-forme.- Pour les fonctions qui retournent du
T, pense covariant ; pour celles qui le consomment, contravariant. En pratique, 95 % du temps tu n'as pas à déclarer la variance toi-même — laisse l'inférence faire, et ne sorsGeneric/Protocolcovariant ([T_co]) que pour les conteneurs en lecture seule.
Ancienne syntaxe (encore courante en codebase < 3.12)
from typing import TypeVar, Generic
T = TypeVar("T", bound="Entity")
class Repository(Generic[T]):
...Fonctionnellement identique. Si ton repo cible 3.12+, préfère la syntaxe class Repository[T: Entity] : plus de TypeVar orphelin à maintenir, scope du paramètre clair.
5. Mise en pratique AI : un client d'agent Claude entièrement typé
C'est ici que tout converge. On sert/appelle un agent LLM avec le SDK AsyncAnthropic, et chaque concept de typing ci-dessus rend l'intégration sûre. On vise le modèle phare claude-opus-4-8 (5 $ / 25 $ par Mtok en entrée/sortie, fenêtre 1M).
a) Le streaming de tokens derrière un Protocol
On définit le contrat dont notre couche métier a besoin, puis on l'implémente avec le SDK. Le reste de l'app dépend du Protocol, jamais du SDK directement — découplage testable.
from __future__ import annotations
from typing import Protocol, Literal, TypedDict
from collections.abc import AsyncIterator
from anthropic import AsyncAnthropic
# --- Contrats (ce dont la couche métier a besoin) -------------------------
class ChatMessage(TypedDict):
role: Literal["user", "assistant"]
content: str
class StreamingChat(Protocol):
"""Tout ce qui sait streamer une conversation satisfait ce protocole."""
async def stream(self, messages: list[ChatMessage]) -> AsyncIterator[str]: ...
# --- Implémentation concrète avec AsyncAnthropic --------------------------
class ClaudeChat:
def __init__(self, client: AsyncAnthropic, model: str = "claude-opus-4-8") -> None:
self._client = client
self._model = model
async def stream(self, messages: list[ChatMessage]) -> AsyncIterator[str]:
# On streame TOUJOURS pour les réponses longues : ça évite les timeouts HTTP.
async with self._client.messages.stream(
model=self._model,
max_tokens=16_000,
thinking={"type": "adaptive"}, # adaptive only — jamais budget_tokens
messages=messages, # type compatible avec l'API
) as stream:
async for text in stream.text_stream: # délègue au helper du SDK
yield text
# ClaudeChat satisfait StreamingChat SANS l'avoir déclaré (structural typing).
async def run(chat: StreamingChat) -> None:
async for chunk in chat.stream([{"role": "user", "content": "Explique mypy"}]):
print(chunk, end="", flush=True)Note senior : on annote le paramètre run(chat: StreamingChat) avec le Protocol, pas avec ClaudeChat. En test, un FakeChat qui rend ["a", "b"] satisfait le protocole sans réseau ni clé API. C'est ça, le typage structurel au service de la testabilité.
b) La boucle tool-use, typée par union discriminée
La boucle d'agent (Claude demande un outil → tu l'exécutes → tu renvoies le résultat → répète) est exactement le cas où Literal + narrowing exhaustif paie. On inspecte response.stop_reason et le type de chaque content block.
from typing import Literal, assert_never
from anthropic import AsyncAnthropic
from anthropic.types import Message, ToolParam
TOOLS: list[ToolParam] = [
{
"name": "get_weather",
"description": "Météo actuelle d'une ville.",
"input_schema": {
"type": "object",
"properties": {"city": {"type": "string"}},
"required": ["city"],
},
}
]
def run_tool(name: str, args: dict[str, object]) -> str:
if name == "get_weather":
return f"22°C et ensoleillé à {args['city']}"
raise ValueError(f"outil inconnu: {name}")
async def agent_loop(client: AsyncAnthropic, user_input: str) -> str:
messages: list[dict[str, object]] = [{"role": "user", "content": user_input}]
while True:
resp: Message = await client.messages.create(
model="claude-opus-4-8",
max_tokens=16_000,
thinking={"type": "adaptive"},
tools=TOOLS,
messages=messages,
)
# narrowing exhaustif sur le stop_reason
stop: Literal["end_turn", "tool_use", "max_tokens", "stop_sequence",
"pause_turn", "refusal"] | None = resp.stop_reason
if stop in ("end_turn", "stop_sequence"):
return "".join(b.text for b in resp.content if b.type == "text")
if stop == "refusal":
return "[refus de sécurité — ne pas réessayer tel quel]"
if stop == "max_tokens":
raise RuntimeError("réponse tronquée — augmenter max_tokens ou streamer")
# stop == "tool_use" : on exécute chaque tool_use block
messages.append({"role": "assistant", "content": resp.content})
tool_results: list[dict[str, object]] = []
for block in resp.content:
if block.type == "tool_use": # narrowing : block est ToolUseBlock
result = run_tool(block.name, dict(block.input))
tool_results.append({
"type": "tool_result",
"tool_use_id": block.id,
"content": result,
})
messages.append({"role": "user", "content": tool_results})Le if block.type == "tool_use" n'est pas qu'une garde runtime : c'est ce qui permet à mypy/pyright de rétrécir le type du bloc et d'autoriser block.name, block.input, block.id sans erreur. Le SDK Anthropic est entièrement typé via des unions discriminées — exploite-les plutôt que de caster en Any.
c) Sorties structurées, typées de bout en bout
Pour extraire du structuré, le SDK expose messages.parse() qui valide la réponse contre un schéma. On modélise la cible avec Pydantic v2 et on récupère un objet typé — plus de json.loads() + dict à la main.
from pydantic import BaseModel
from anthropic import AsyncAnthropic
class Contact(BaseModel):
name: str
email: str
wants_demo: bool
async def extract(client: AsyncAnthropic, text: str) -> Contact:
resp = await client.messages.parse(
model="claude-opus-4-8",
max_tokens=1024,
messages=[{"role": "user", "content": f"Extrais le contact: {text}"}],
output_format=Contact, # native structured outputs (Pydantic v2)
)
contact = resp.parsed_output # type: Contact (pas dict, pas Any)
return contactresp.parsed_output est typé Contact : ton IDE complète .email, mypy vérifie les usages en aval. La modélisation Pydantic + le typing statique se renforcent — Pydantic valide au runtime, le type checker valide à la frappe.
⚙️ En production
Modes d'échec.
- L'annotation ment. Le runtime ne vérifie rien (hors Pydantic) : une fonction annotée
-> intpeut très bien renvoyerNonesi tu n'as jamais passémypy. Conséquence : les annotations sans CI qui lancemypy --strictsont du décor. Bloque la PR si le type check échoue. Anyest contagieux. Un seulAny(souvent unjson.loads()non typé, un cast paresseux, ou une lib non typée) se propage et désactive silencieusement la vérification en aval. Activewarn_return_anyetdisallow_any_explicitdansmypy.@runtime_checkablesurvendu. Il ne vérifie que la présence d'attributs, pas les signatures — ne l'utilise pas comme barrière de sécurité.- Forward references / imports circulaires. Les annotations de types qui s'auto-référencent (
Block = TextBlock | ...) ou créent des cycles d'import se gèrent avecfrom __future__ import annotations(rend toutes les annotations lazy, évaluées enstr) et le blocif TYPE_CHECKING:pour les imports qui ne servent qu'au typage.
Performance.
- Le typing est gratuit au runtime dans 99 % des cas : annotations effacées,
Protocol/Generic/TypedDict/Literaln'existent pas après le parsing. Pas de raison de les éviter « pour la perf ». from __future__ import annotations(PEP 563) rend les annotations paresseuses → import plus rapide, plus de cycles. À ce jour c'est le défaut recommandé sur tout nouveau module.- Pydantic v2, lui, valide au runtime (cœur en Rust, rapide mais non nul). Ne valide pas ce qui vient d'une source fiable : utilise
TypedDict/dataclassà l'intérieur du système, Pydantic seulement aux frontières (entrée API, sortie LLM, config externe).
Sécurité.
- Les types réduisent les bugs d'injection de forme : un
Literal["user","assistant"]empêche un rôle forgé d'entrer dans ta boucle de messages. Mais ce n'est pas une validation de sécurité — le runtime accepte n'importe quelstr. Pour des données non fiables, valide avec Pydantic (qui refuse au runtime). - Côté LLM : vérifie toujours
stop_reason == "refusal"avant de lireresponse.content[0], sinonIndexErrorsur une réponse refusée. Le typeLiteraldustop_reasonte rappelle le cas viaassert_never, mais c'est à toi de le traiter.
Observabilité.
- Les unions discriminées rendent le logging trivial :
log.info("block", type=block.type)et tu sais exactement ce qui transite. - Pour la facturation/le suivi tokens, le SDK expose
response.usagetypé (input_tokens,output_tokens,cache_read_input_tokens). Logue-le — surclaude-opus-4-8à 5 $/25 $ par Mtok, le cache read (~0,1×) et le streaming changent la facture.
Les arbitrages du senior.
- Rigueur vs vélocité : commence en
mypynon-strict sur du code exploratoire, passe en--strictsur les modules cœur (domaine, frontières I/O, boucle d'agent). Tout en strict d'emblée tue la vélocité sur un prototype. - Protocol vs ABC :
Protocolpar défaut (découplage, testabilité) ; ABC seulement quand tu veux fournir du comportement partagé concret. - TypedDict vs Pydantic :
TypedDictà l'intérieur (rapide, descriptif), Pydantic aux frontières (validant). Ne paie pas la validation Pydantic sur des données que tu as déjà construites toi-même. - Dépends d'abstractions, pas du SDK : encapsule
AsyncAnthropicderrière unProtocolmétier. Le jour où tu changes de provider ou que tu mockes en test, seule l'implémentation bouge.
🏋️ Exercices
Exercice 1 — Union discriminée exhaustive (implémenter)
Objectif. Modéliser les content blocks d'une réponse LLM (text, tool_use, thinking) en dataclasses frozen avec un champ type: Literal[...], puis écrire summarize(blocks: list[Block]) -> str qui gère chaque cas via match + assert_never. Ajoute ensuite un 4ᵉ type de bloc et vérifie que mypy t'oblige à traiter le nouveau cas.
Indice/Solution. Reprends le pattern de la section 1. La clé : assert_never(block) dans la branche case _. Lance mypy fichier.py avant d'avoir géré le nouveau cas → tu dois voir une erreur Argument 1 to "assert_never" has incompatible type. C'est le signal que ton exhaustivité est vérifiée.
Exercice 2 — Repository générique borné (implémenter)
Objectif. Écrire Repository[T: Entity] (PEP 695) avec add, get(id) -> T | None, delete, et une méthode find(predicate: Callable[[T], bool]) -> list[T]. Instancie un Repository[User] et prouve que repo.add(SomeOtherType()) est rejeté par mypy.
Indice/Solution. Section 4 pour la base. Pour find, importe Callable depuis collections.abc. Vérifie que repo.get(1) est inféré User | None (survole dans l'IDE ou reveal_type(repo.get(1)) → mypy l'affiche). Piège : si tu oublies la borne : Entity, item.id dans add ne type-checkera pas.
Exercice 3 — Protocol + double implémentation + fake de test (production-grade)
Objectif. Définir un Protocol StreamingChat (comme en 5a). Fournir deux implémentations : ClaudeChat (réelle, AsyncAnthropic) et FakeChat (rend une liste figée de chunks, zéro réseau). Écrire un test pytest qui passe FakeChat à une fonction run(chat: StreamingChat) et asserte le résultat. Aucune des deux classes ne doit hériter du Protocol.
Indice/Solution. FakeChat.stream est un async def qui yielde depuis une liste. Le test : chat = FakeChat(["Hel", "lo"]) ; assert await collect(chat) == "Hello". Le point pédagogique : FakeChat satisfait StreamingChat par sa forme seule. Si tu changes la signature du Protocol, mypy casse les deux implémentations — c'est voulu.
Exercice 4 — TypedDict aux frontières, Pydantic au runtime (production-grade)
Objectif. Typer le payload d'appel Messages avec un TypedDict (RequestPayload, section 2). Puis, pour la réponse d'extraction, modéliser une cible Pydantic v2 et utiliser messages.parse(..., output_format=Model). Compare : où le type est-il vérifié à la frappe (TypedDict) vs validé au runtime (Pydantic) ? Force une réponse invalide et observe qui lève quoi.
Indice/Solution. Le TypedDict ne lèvera jamais au runtime même si tu mets role: "robot" — seul mypy le voit. Pydantic, lui, lève ValidationError au runtime si la réponse ne colle pas au schéma. Écris-le noir sur blanc dans un commentaire : c'est la distinction « static vs runtime » à internaliser.
Exercice 5 — Boucle tool-use durcie (casser puis réparer)
Objectif. Repars de agent_loop (section 5b). Introduis trois bugs et répare-les : (1) ne pas gérer stop_reason == "refusal" → provoque un IndexError sur une réponse refusée ; (2) caster block.input en Any → montre comment mypy perd toute garantie en aval ; (3) oublier assert_never sur le stop_reason → ajoute un nouveau stop_reason et montre que le bug passe silencieusement.
Indice/Solution. Pour (1), le fix est le garde if stop == "refusal": return ... avant de lire resp.content. Pour (2), remplace dict(block.input) par un cast Any et observe que run_tool ne profite plus d'aucune vérif. Pour (3), exhaustivité via assert_never — la même technique que l'exercice 1, appliquée au flux de contrôle d'un agent réel.
Exercice 6 — Cache prompt + usage typé (production-grade)
Objectif. Étendre ClaudeChat pour logger response.usage (typé) après chaque tour, et calculer le coût estimé sur claude-opus-4-8 (5 $/Mtok entrée, 25 $/Mtok sortie). Ajoute un cache_control: {"type": "ephemeral"} sur un long préfixe système et prouve via usage.cache_read_input_tokens que le cache est lu (≈ 0,1× du prix d'entrée) sur le 2ᵉ appel.
Indice/Solution. Avec messages.stream, récupère le message final via await stream.get_final_message() puis lis .usage. Coût ≈ usage.input_tokens * 5e-6 + usage.output_tokens * 25e-6. Pour le cache : place le cache_control sur le dernier bloc système, envoie deux requêtes au préfixe identique, et vérifie que cache_read_input_tokens > 0 au second tour. Si c'est zéro, un invalidateur silencieux (timestamp, UUID dans le préfixe) casse le cache.
🎤 En entretien
Q : Quelle est la différence entre Protocol et une ABC (abc.ABC) ? Quand choisir l'un ? R : Protocol = typage structurel (la classe matche par sa forme, sans héritage ni import du protocole) → idéal pour découpler et mocker ; ABC = typage nominal (il faut hériter explicitement) → utile quand on veut fournir du comportement partagé concret. Par défaut : Protocol.
Q : Les annotations de type ralentissent-elles l'exécution ? R : Non dans le cas général — elles sont effacées après le parsing (Protocol, Generic, TypedDict, Literal n'existent pas au runtime). Avec from __future__ import annotations elles deviennent même paresseuses. La seule exception est Pydantic, qui valide au runtime — à réserver aux frontières (entrée non fiable), pas aux données qu'on a déjà construites.
Q : TypedDict ou Pydantic pour un payload JSON ? R : TypedDict pour décrire un dict sans coût ni validation (frontières internes, données déjà fiables) ; Pydantic dès qu'il faut valider/coercer une entrée non fiable et lever au runtime. TypedDict ne lève jamais — c'est purement statique.
Q : Comment garantir qu'on a traité tous les cas d'une union (stop_reason, content blocks) ? R : Union discriminée sur un champ Literal + match + assert_never(x) dans la branche par défaut. Le jour où on ajoute un variant sans le gérer, mypy échoue à la compilation — contrôle d'exhaustivité gratuit, exactement comme assertNever en TypeScript.