[{"data":1,"prerenderedAt":768},["ShallowReactive",2],{"post-fr-dataclasses-pydantic-typeddict":3},{"id":4,"title":5,"body":6,"date":754,"description":16,"excerpt":755,"extension":756,"meta":757,"navigation":85,"path":758,"readTime":113,"seo":759,"slug":760,"stem":761,"tags":762,"__hash__":767},"fr_blog/fr/blog/dataclasses-pydantic-typeddict.md","Dataclasses, Pydantic, TypedDict : lequel choisir et pourquoi",{"type":7,"value":8,"toc":746},"minimark",[9,13,17,22,25,32,50,56,59,63,152,155,162,166,240,243,249,293,299,303,424,427,484,490,494,497,568,571,574,578,581,739,742],[10,11,5],"h1",{"id":12},"dataclasses-pydantic-typeddict-lequel-choisir-et-pourquoi",[14,15,16],"p",{},"C'est une question que chaque équipe Python se pose tôt ou tard. Les réponses qu'on trouve en ligne sont souvent du type \"Pydantic pour les APIs, dataclasses pour le reste\" — ce qui est un début, mais ne couvre pas les cas limites où ce choix a un impact réel. Voici les règles de décision que j'applique en pratique.",[18,19,21],"h2",{"id":20},"comprendre-ce-que-chacun-fait-réellement","Comprendre ce que chacun fait réellement",[14,23,24],{},"Avant les règles, un rappel sur ce que chaque outil résout :",[14,26,27,31],{},[28,29,30],"strong",{},"TypedDict"," est une annotation de type pure. Il ne fait rien à l'exécution — il informe juste le type checker (mypy, pyright) sur la forme d'un dictionnaire. Aucun overhead, aucune validation.",[14,33,34,37,38,42,43,42,46,49],{},[28,35,36],{},"Dataclass"," est un générateur de classes Python standard. Elle crée automatiquement ",[39,40,41],"code",{},"__init__",", ",[39,44,45],{},"__repr__",[39,47,48],{},"__eq__"," à partir des annotations. Pas de validation des types à l'exécution.",[14,51,52,55],{},[28,53,54],{},"Pydantic BaseModel"," est un système de validation complet. Il convertit et valide les données à l'exécution, lève des erreurs détaillées, sérialise/désérialise JSON, et génère des schémas JSON Schema.",[14,57,58],{},"Ce ne sont pas trois façons de faire la même chose — ce sont trois outils avec des responsabilités différentes.",[18,60,62],{"id":61},"règle-1-typeddict-pour-les-dictionnaires-dont-tu-ne-contrôles-pas-la-création","Règle 1 : TypedDict pour les dictionnaires dont tu ne contrôles pas la création",[64,65,70],"pre",{"className":66,"code":67,"language":68,"meta":69,"style":69},"language-python shiki shiki-themes github-dark github-light","from typing import TypedDict\n\n# Données retournées par une API externe — tu ne les construis pas, tu les lis\nclass GrxCertificate(TypedDict):\n    id: str\n    volume: float\n    period_from: str\n    period_to: str\n    status: str\n\n# Usage\ndef process_certificate(cert: GrxCertificate) -> float:\n    return cert[\"volume\"] * 1.05  # Type checker valide l'accès\n","python","",[39,71,72,80,87,93,99,105,111,117,123,129,134,140,146],{"__ignoreMap":69},[73,74,77],"span",{"class":75,"line":76},"line",1,[73,78,79],{},"from typing import TypedDict\n",[73,81,83],{"class":75,"line":82},2,[73,84,86],{"emptyLinePlaceholder":85},true,"\n",[73,88,90],{"class":75,"line":89},3,[73,91,92],{},"# Données retournées par une API externe — tu ne les construis pas, tu les lis\n",[73,94,96],{"class":75,"line":95},4,[73,97,98],{},"class GrxCertificate(TypedDict):\n",[73,100,102],{"class":75,"line":101},5,[73,103,104],{},"    id: str\n",[73,106,108],{"class":75,"line":107},6,[73,109,110],{},"    volume: float\n",[73,112,114],{"class":75,"line":113},7,[73,115,116],{},"    period_from: str\n",[73,118,120],{"class":75,"line":119},8,[73,121,122],{},"    period_to: str\n",[73,124,126],{"class":75,"line":125},9,[73,127,128],{},"    status: str\n",[73,130,132],{"class":75,"line":131},10,[73,133,86],{"emptyLinePlaceholder":85},[73,135,137],{"class":75,"line":136},11,[73,138,139],{},"# Usage\n",[73,141,143],{"class":75,"line":142},12,[73,144,145],{},"def process_certificate(cert: GrxCertificate) -> float:\n",[73,147,149],{"class":75,"line":148},13,[73,150,151],{},"    return cert[\"volume\"] * 1.05  # Type checker valide l'accès\n",[14,153,154],{},"TypedDict est idéal pour typer des dictionnaires qui viennent de l'extérieur (réponses JSON, résultats de requêtes SQL, configs YAML) sans les transformer en objets. L'overhead à l'exécution est nul — c'est purement statique.",[14,156,157,158,161],{},"La limite : TypedDict ne valide rien à l'exécution. Si l'API renvoie un ",[39,159,160],{},"volume"," sous forme de string, ton code plante plus loin, pas à la désérialisation.",[18,163,165],{"id":164},"règle-2-dataclass-pour-les-modèles-internes-sans-validation","Règle 2 : Dataclass pour les modèles internes sans validation",[64,167,169],{"className":66,"code":168,"language":68,"meta":69,"style":69},"from dataclasses import dataclass, field\nfrom datetime import datetime\n\n@dataclass\nclass CertificateAggregate:\n    account_id: str\n    total_volume: float\n    certificate_count: int\n    computed_at: datetime = field(default_factory=datetime.now)\n\n    def average_volume(self) -> float:\n        if self.certificate_count == 0:\n            return 0.0\n        return self.total_volume / self.certificate_count\n",[39,170,171,176,181,185,190,195,200,205,210,215,219,224,229,234],{"__ignoreMap":69},[73,172,173],{"class":75,"line":76},[73,174,175],{},"from dataclasses import dataclass, field\n",[73,177,178],{"class":75,"line":82},[73,179,180],{},"from datetime import datetime\n",[73,182,183],{"class":75,"line":89},[73,184,86],{"emptyLinePlaceholder":85},[73,186,187],{"class":75,"line":95},[73,188,189],{},"@dataclass\n",[73,191,192],{"class":75,"line":101},[73,193,194],{},"class CertificateAggregate:\n",[73,196,197],{"class":75,"line":107},[73,198,199],{},"    account_id: str\n",[73,201,202],{"class":75,"line":113},[73,203,204],{},"    total_volume: float\n",[73,206,207],{"class":75,"line":119},[73,208,209],{},"    certificate_count: int\n",[73,211,212],{"class":75,"line":125},[73,213,214],{},"    computed_at: datetime = field(default_factory=datetime.now)\n",[73,216,217],{"class":75,"line":131},[73,218,86],{"emptyLinePlaceholder":85},[73,220,221],{"class":75,"line":136},[73,222,223],{},"    def average_volume(self) -> float:\n",[73,225,226],{"class":75,"line":142},[73,227,228],{},"        if self.certificate_count == 0:\n",[73,230,231],{"class":75,"line":148},[73,232,233],{},"            return 0.0\n",[73,235,237],{"class":75,"line":236},14,[73,238,239],{},"        return self.total_volume / self.certificate_count\n",[14,241,242],{},"Les dataclasses sont parfaites pour les objets que tu construis toi-même dans ton code métier — résultats d'agrégation, objets intermédiaires de traitement, value objects du domaine. Elles sont plus légères que Pydantic et plus explicites que des dictionnaires.",[14,244,245,248],{},[39,246,247],{},"@dataclass(frozen=True)"," les rend immutables — utile pour les value objects :",[64,250,252],{"className":66,"code":251,"language":68,"meta":69,"style":69},"@dataclass(frozen=True)\nclass DateRange:\n    start: str\n    end: str\n\n    def __post_init__(self):\n        if self.start > self.end:\n            raise ValueError(f\"start ({self.start}) doit être avant end ({self.end})\")\n",[39,253,254,259,264,269,274,278,283,288],{"__ignoreMap":69},[73,255,256],{"class":75,"line":76},[73,257,258],{},"@dataclass(frozen=True)\n",[73,260,261],{"class":75,"line":82},[73,262,263],{},"class DateRange:\n",[73,265,266],{"class":75,"line":89},[73,267,268],{},"    start: str\n",[73,270,271],{"class":75,"line":95},[73,272,273],{},"    end: str\n",[73,275,276],{"class":75,"line":101},[73,277,86],{"emptyLinePlaceholder":85},[73,279,280],{"class":75,"line":107},[73,281,282],{},"    def __post_init__(self):\n",[73,284,285],{"class":75,"line":113},[73,286,287],{},"        if self.start > self.end:\n",[73,289,290],{"class":75,"line":119},[73,291,292],{},"            raise ValueError(f\"start ({self.start}) doit être avant end ({self.end})\")\n",[14,294,295,298],{},[39,296,297],{},"__post_init__"," permet d'ajouter de la validation sans Pydantic — suffisant pour des invariants simples.",[18,300,302],{"id":301},"règle-3-pydantic-pour-tout-ce-qui-touche-les-frontières-du-système","Règle 3 : Pydantic pour tout ce qui touche les frontières du système",[64,304,306],{"className":66,"code":305,"language":68,"meta":69,"style":69},"from pydantic import BaseModel, Field, field_validator\nfrom typing import Literal\n\nclass CertificateRequest(BaseModel):\n    account_id: str = Field(min_length=3, max_length=50)\n    volume: float = Field(gt=0, description=\"Volume en MWh\")\n    technology: Literal[\"WIND\", \"SOLAR\", \"HYDRO\", \"BIOMASS\"]\n    period_from: str\n    period_to: str\n\n    @field_validator(\"period_to\")\n    @classmethod\n    def period_to_after_from(cls, v: str, info) -> str:\n        if \"period_from\" in info.data and v \u003C= info.data[\"period_from\"]:\n            raise ValueError(\"period_to doit être postérieure à period_from\")\n        return v\n\nclass CertificateResponse(BaseModel):\n    id: str\n    volume: float\n    status: Literal[\"ACTIVE\", \"CANCELLED\", \"TRANSFERRED\"]\n\n    model_config = {\"from_attributes\": True}  # Compatibilité avec les ORM\n",[39,307,308,313,318,322,327,332,337,342,346,350,354,359,364,369,374,380,386,391,397,402,407,413,418],{"__ignoreMap":69},[73,309,310],{"class":75,"line":76},[73,311,312],{},"from pydantic import BaseModel, Field, field_validator\n",[73,314,315],{"class":75,"line":82},[73,316,317],{},"from typing import Literal\n",[73,319,320],{"class":75,"line":89},[73,321,86],{"emptyLinePlaceholder":85},[73,323,324],{"class":75,"line":95},[73,325,326],{},"class CertificateRequest(BaseModel):\n",[73,328,329],{"class":75,"line":101},[73,330,331],{},"    account_id: str = Field(min_length=3, max_length=50)\n",[73,333,334],{"class":75,"line":107},[73,335,336],{},"    volume: float = Field(gt=0, description=\"Volume en MWh\")\n",[73,338,339],{"class":75,"line":113},[73,340,341],{},"    technology: Literal[\"WIND\", \"SOLAR\", \"HYDRO\", \"BIOMASS\"]\n",[73,343,344],{"class":75,"line":119},[73,345,116],{},[73,347,348],{"class":75,"line":125},[73,349,122],{},[73,351,352],{"class":75,"line":131},[73,353,86],{"emptyLinePlaceholder":85},[73,355,356],{"class":75,"line":136},[73,357,358],{},"    @field_validator(\"period_to\")\n",[73,360,361],{"class":75,"line":142},[73,362,363],{},"    @classmethod\n",[73,365,366],{"class":75,"line":148},[73,367,368],{},"    def period_to_after_from(cls, v: str, info) -> str:\n",[73,370,371],{"class":75,"line":236},[73,372,373],{},"        if \"period_from\" in info.data and v \u003C= info.data[\"period_from\"]:\n",[73,375,377],{"class":75,"line":376},15,[73,378,379],{},"            raise ValueError(\"period_to doit être postérieure à period_from\")\n",[73,381,383],{"class":75,"line":382},16,[73,384,385],{},"        return v\n",[73,387,389],{"class":75,"line":388},17,[73,390,86],{"emptyLinePlaceholder":85},[73,392,394],{"class":75,"line":393},18,[73,395,396],{},"class CertificateResponse(BaseModel):\n",[73,398,400],{"class":75,"line":399},19,[73,401,104],{},[73,403,405],{"class":75,"line":404},20,[73,406,110],{},[73,408,410],{"class":75,"line":409},21,[73,411,412],{},"    status: Literal[\"ACTIVE\", \"CANCELLED\", \"TRANSFERRED\"]\n",[73,414,416],{"class":75,"line":415},22,[73,417,86],{"emptyLinePlaceholder":85},[73,419,421],{"class":75,"line":420},23,[73,422,423],{},"    model_config = {\"from_attributes\": True}  # Compatibilité avec les ORM\n",[14,425,426],{},"Pydantic gagne sur toutes les frontières : entrées HTTP (corps de requête, paramètres), sorties JSON, lecture de configuration, lecture de variables d'environnement.",[64,428,430],{"className":66,"code":429,"language":68,"meta":69,"style":69},"from pydantic_settings import BaseSettings\n\nclass Settings(BaseSettings):\n    database_url: str\n    redis_url: str\n    secret_key: str\n    debug: bool = False\n\n    model_config = {\"env_file\": \".env\"}\n\nsettings = Settings()  # Lève une erreur claire si DATABASE_URL est absente\n",[39,431,432,437,441,446,451,456,461,466,470,475,479],{"__ignoreMap":69},[73,433,434],{"class":75,"line":76},[73,435,436],{},"from pydantic_settings import BaseSettings\n",[73,438,439],{"class":75,"line":82},[73,440,86],{"emptyLinePlaceholder":85},[73,442,443],{"class":75,"line":89},[73,444,445],{},"class Settings(BaseSettings):\n",[73,447,448],{"class":75,"line":95},[73,449,450],{},"    database_url: str\n",[73,452,453],{"class":75,"line":101},[73,454,455],{},"    redis_url: str\n",[73,457,458],{"class":75,"line":107},[73,459,460],{},"    secret_key: str\n",[73,462,463],{"class":75,"line":113},[73,464,465],{},"    debug: bool = False\n",[73,467,468],{"class":75,"line":119},[73,469,86],{"emptyLinePlaceholder":85},[73,471,472],{"class":75,"line":125},[73,473,474],{},"    model_config = {\"env_file\": \".env\"}\n",[73,476,477],{"class":75,"line":131},[73,478,86],{"emptyLinePlaceholder":85},[73,480,481],{"class":75,"line":136},[73,482,483],{},"settings = Settings()  # Lève une erreur claire si DATABASE_URL est absente\n",[14,485,486,489],{},[39,487,488],{},"pydantic-settings"," est particulièrement utile — il lit les variables d'environnement, les cast dans les bons types, et lève des erreurs explicites au démarrage si une variable obligatoire est manquante.",[18,491,493],{"id":492},"les-performances-quand-ça-compte-vraiment","Les performances : quand ça compte vraiment",[14,495,496],{},"Pydantic v2 (réécrit en Rust) est considérablement plus rapide que v1, mais reste plus lent que les dataclasses pour la construction d'objets :",[498,499,500,519],"table",{},[501,502,503],"thead",{},[504,505,506,510,513,516],"tr",{},[507,508,509],"th",{},"Outil",[507,511,512],{},"Construction (relative)",[507,514,515],{},"Validation",[507,517,518],{},"Sérialisation JSON",[520,521,522,536,552],"tbody",{},[504,523,524,527,530,533],{},[525,526,30],"td",{},[525,528,529],{},"1x",[525,531,532],{},"Aucune",[525,534,535],{},"Manuel",[504,537,538,540,543,547],{},[525,539,36],{},[525,541,542],{},"1.2x",[525,544,545],{},[39,546,297],{},[525,548,549],{},[39,550,551],{},"dataclasses.asdict()",[504,553,554,557,560,563],{},[525,555,556],{},"Pydantic v2",[525,558,559],{},"3-5x",[525,561,562],{},"Complète",[525,564,565],{},[39,566,567],{},".model_dump_json()",[14,569,570],{},"Sur un endpoint FastAPI qui traite 1000 requêtes/seconde avec des modèles simples, la différence Pydantic vs dataclass est négligeable. Elle devient visible sur des pipelines de traitement qui instancient des millions d'objets — ETL, traitement de fichiers volumineux.",[14,572,573],{},"La règle pratique : n'optimise pas prématurément. Pydantic aux frontières, dataclasses en interne — cette séparation donne de bonnes performances par défaut sans micro-optimisation.",[18,575,577],{"id":576},"combiner-les-trois","Combiner les trois",[14,579,580],{},"Sur un projet réel, les trois coexistent naturellement :",[64,582,584],{"className":66,"code":583,"language":68,"meta":69,"style":69},"from typing import TypedDict\nfrom dataclasses import dataclass\nfrom pydantic import BaseModel\n\n# TypedDict : réponse brute de l'API externe\nclass RawApiResponse(TypedDict):\n    data: list[dict]\n    meta: dict\n\n# Pydantic : validation et parsing de la réponse\nclass Certificate(BaseModel):\n    id: str\n    volume: float\n    status: str\n\n# Dataclass : objet métier interne après traitement\n@dataclass\nclass CertificateReport:\n    total_volume: float\n    active_count: int\n    cancelled_count: int\n\ndef process_response(raw: RawApiResponse) -> CertificateReport:\n    certificates = [Certificate.model_validate(item) for item in raw[\"data\"]]\n    active = [c for c in certificates if c.status == \"ACTIVE\"]\n    cancelled = [c for c in certificates if c.status == \"CANCELLED\"]\n    return CertificateReport(\n        total_volume=sum(c.volume for c in certificates),\n        active_count=len(active),\n        cancelled_count=len(cancelled),\n    )\n",[39,585,586,590,595,600,604,609,614,619,624,628,633,638,642,646,650,654,659,663,668,672,677,682,686,691,697,703,709,715,721,727,733],{"__ignoreMap":69},[73,587,588],{"class":75,"line":76},[73,589,79],{},[73,591,592],{"class":75,"line":82},[73,593,594],{},"from dataclasses import dataclass\n",[73,596,597],{"class":75,"line":89},[73,598,599],{},"from pydantic import BaseModel\n",[73,601,602],{"class":75,"line":95},[73,603,86],{"emptyLinePlaceholder":85},[73,605,606],{"class":75,"line":101},[73,607,608],{},"# TypedDict : réponse brute de l'API externe\n",[73,610,611],{"class":75,"line":107},[73,612,613],{},"class RawApiResponse(TypedDict):\n",[73,615,616],{"class":75,"line":113},[73,617,618],{},"    data: list[dict]\n",[73,620,621],{"class":75,"line":119},[73,622,623],{},"    meta: dict\n",[73,625,626],{"class":75,"line":125},[73,627,86],{"emptyLinePlaceholder":85},[73,629,630],{"class":75,"line":131},[73,631,632],{},"# Pydantic : validation et parsing de la réponse\n",[73,634,635],{"class":75,"line":136},[73,636,637],{},"class Certificate(BaseModel):\n",[73,639,640],{"class":75,"line":142},[73,641,104],{},[73,643,644],{"class":75,"line":148},[73,645,110],{},[73,647,648],{"class":75,"line":236},[73,649,128],{},[73,651,652],{"class":75,"line":376},[73,653,86],{"emptyLinePlaceholder":85},[73,655,656],{"class":75,"line":382},[73,657,658],{},"# Dataclass : objet métier interne après traitement\n",[73,660,661],{"class":75,"line":388},[73,662,189],{},[73,664,665],{"class":75,"line":393},[73,666,667],{},"class CertificateReport:\n",[73,669,670],{"class":75,"line":399},[73,671,204],{},[73,673,674],{"class":75,"line":404},[73,675,676],{},"    active_count: int\n",[73,678,679],{"class":75,"line":409},[73,680,681],{},"    cancelled_count: int\n",[73,683,684],{"class":75,"line":415},[73,685,86],{"emptyLinePlaceholder":85},[73,687,688],{"class":75,"line":420},[73,689,690],{},"def process_response(raw: RawApiResponse) -> CertificateReport:\n",[73,692,694],{"class":75,"line":693},24,[73,695,696],{},"    certificates = [Certificate.model_validate(item) for item in raw[\"data\"]]\n",[73,698,700],{"class":75,"line":699},25,[73,701,702],{},"    active = [c for c in certificates if c.status == \"ACTIVE\"]\n",[73,704,706],{"class":75,"line":705},26,[73,707,708],{},"    cancelled = [c for c in certificates if c.status == \"CANCELLED\"]\n",[73,710,712],{"class":75,"line":711},27,[73,713,714],{},"    return CertificateReport(\n",[73,716,718],{"class":75,"line":717},28,[73,719,720],{},"        total_volume=sum(c.volume for c in certificates),\n",[73,722,724],{"class":75,"line":723},29,[73,725,726],{},"        active_count=len(active),\n",[73,728,730],{"class":75,"line":729},30,[73,731,732],{},"        cancelled_count=len(cancelled),\n",[73,734,736],{"class":75,"line":735},31,[73,737,738],{},"    )\n",[14,740,741],{},"Chaque outil à sa place : TypedDict pour l'externe brut, Pydantic pour la validation à la frontière, dataclass pour la logique interne. C'est cette séparation qui rend le code lisible et maintenable à long terme.",[743,744,745],"style",{},"html .dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html.dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html .default .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}",{"title":69,"searchDepth":82,"depth":82,"links":747},[748,749,750,751,752,753],{"id":20,"depth":82,"text":21},{"id":61,"depth":82,"text":62},{"id":164,"depth":82,"text":165},{"id":301,"depth":82,"text":302},{"id":492,"depth":82,"text":493},{"id":576,"depth":82,"text":577},"2025-01-06",null,"md",{},"/fr/blog/dataclasses-pydantic-typeddict",{"title":5,"description":16},"dataclasses-pydantic-typeddict","fr/blog/dataclasses-pydantic-typeddict",[763,764,765,766],"Python","Pydantic","Typage","FastAPI","2JBqIxVKy1nLHjifj5hrBFPtnKxy0BhMcse-p9Aa9uk",1774645636218]