Skip to content

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. Literal ferme l'ensemble des valeurs autorisées (un Enum léger qui vit dans les types), TypedDict type les dicts JSON sans payer le coût d'une classe, Protocol exprime le duck typing de manière vérifiable (structural typing, à la TypeScript interface), et Generic/TypeVar rend ton code réutilisable et type-safe sur plusieurs types. Tout ça est effacé à l'exécution (typing ≈ documentation exécutable vérifiée par mypy/pyright), sauf si tu demandes explicitement le contraire. Pour qui sert un agent LLM, ces outils sont ce qui transforme une réponse dict[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/pyright sont 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 :

python
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

python
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 ICI

assert_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)

python
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 dort

Ici 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.

python
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 NotRequired

Points clés que les devs ratent :

  • TypedDict n'existe qu'à la vérification. isinstance(payload, RequestPayload) lève TypeError — c'est un dict à l'exécution, point.
  • Pas de valeurs par défaut, pas de méthodes. Si tu veux de la logique → dataclass ou Pydantic.
  • NotRequired/Required gèrent l'optionalité champ par champ ; total=False rend tout optionnel.
  • Utilise TypedDict pour 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 :

BesoinOutil
Annoter un dict déjà construit, zéro coût runtimeTypedDict
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.

python
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 typing

Pourquoi 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 Protocol dont elle a besoin, les couches basses le satisfont implicitement.

@runtime_checkable et son piège

python
@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)

python
# 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
# 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 :

python
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

  • T borné par Entity ([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 sors Generic/Protocol covariant ([T_co]) que pour les conteneurs en lecture seule.

Ancienne syntaxe (encore courante en codebase < 3.12)

python
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.

python
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.

python
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.

python
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 contact

resp.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 -> int peut très bien renvoyer None si tu n'as jamais passé mypy. Conséquence : les annotations sans CI qui lance mypy --strict sont du décor. Bloque la PR si le type check échoue.
  • Any est contagieux. Un seul Any (souvent un json.loads() non typé, un cast paresseux, ou une lib non typée) se propage et désactive silencieusement la vérification en aval. Active warn_return_any et disallow_any_explicit dans mypy.
  • @runtime_checkable survendu. 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 avec from __future__ import annotations (rend toutes les annotations lazy, évaluées en str) et le bloc if 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/Literal n'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 quel str. Pour des données non fiables, valide avec Pydantic (qui refuse au runtime).
  • Côté LLM : vérifie toujours stop_reason == "refusal" avant de lire response.content[0], sinon IndexError sur une réponse refusée. Le type Literal du stop_reason te rappelle le cas via assert_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.usage typé (input_tokens, output_tokens, cache_read_input_tokens). Logue-le — sur claude-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 mypy non-strict sur du code exploratoire, passe en --strict sur 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 : Protocol par 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 AsyncAnthropic derrière un Protocol mé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.

Bibliothèque tech perso — Achref