Fiche 00.06 — Codable en conditions réelles
Objectif
Décoder/encoder du JSON propre et résilient avec Codable : renommage de clés, snake_case, dates, champs optionnels, init(from:) custom, séparation DTO/Model et erreurs lisibles. Pas la version « tutoriel » : la version qu'on attend en mission.
1. Le rappel UIKit → Swift moderne
En UIKit/Objective-C tu parsais souvent à la main avec JSONSerialization et des casts as? [String: Any]. Verbeux, fragile, plein de ?? "".
Codable (= Decodable & Encodable) génère le parsing pour toi à partir du type. Trois protocoles :
Decodable: JSON → struct (le plus courant, réponses API).Encodable: struct → JSON (corps de requête POST).Codable: les deux.
Règle : si tu n'as besoin que de décoder, déclare Decodable (pas Codable). Ça force à réfléchir au sens des flux.
2. CodingKeys : renommer une clé
Quand une clé JSON ne correspond pas à un nom Swift idiomatique, tu déclares un enum CodingKeys.
Piège classique : dès que tu déclares CodingKeys, tu dois lister toutes les propriétés décodées, pas seulement celles renommées. Oublier une propriété = elle n'est plus décodée (et erreur si non optionnelle).
3. keyDecodingStrategy = .convertFromSnakeCase
Si toute l'API est en snake_case, ne fais pas 40 CodingKeys à la main. Pose la stratégie sur le décodeur.
.convertFromSnakeCase transforme full_name → fullName automatiquement. Tu n'écris plus de CodingKeys pour le simple snake_case.
À retenir : la stratégie s'applique d'abord, puis tes CodingKeys si tu en as. Donc si une clé est irrégulière (avatar_url → avatarURL et pas avatarUrl), tu remets un CodingKeys ciblé pour cette clé-là, et la stratégie gère le reste.
Pose le décodeur une seule fois (partagé)
Anti-pattern : recréer un JSONDecoder configuré dans chaque appel réseau.
Préférable : un décodeur unique, partagé par tout l'APIClient.
C'est l'équivalent moderne d'avoir une config réseau centralisée : une source de vérité.
4. Dates : .iso8601 et formats custom
La plupart des API REST renvoient de l'ISO 8601 (2026-06-28T14:30:00Z).
Attention : .iso8601 ne gère pas les millisecondes (...30.123Z). Si l'API en envoie, il te faut un format custom via un DateFormatter ou, mieux, une closure.
Pour un format vraiment maison ("28/06/2026"), même principe avec un DateFormatter dont tu fixes locale = Locale(identifier: "en_US_POSIX") et timeZone. Sans en_US_POSIX, un appareil configuré en calendrier non grégorien parse faux : erreur classique en prod.
5. Champs optionnels, manquants et valeurs par défaut
Trois cas distincts à ne pas confondre :
Cas optionnel : si la propriété est Optional, une clé absente OU null donne nil. Codable gère ça gratuitement.
Valeur par défaut : Codable ne sait PAS appliquer une valeur par défaut sur une clé absente automatiquement (la valeur par défaut du struct est ignorée au décodage). Il faut decodeIfPresent dans un init(from:).
decodeIfPresent renvoie nil si la clé est absente ou null, sans throw. Tu combines avec ?? pour ta valeur par défaut.
Astuce entretien : un @propertyWrapper @Default est une réponse « senior » à ce besoin, mais decodeIfPresent + init(from:) est la solution standard sans dépendance.
6. init(from:) custom et nestedContainer
Quand le JSON ne colle pas à la forme que tu veux dans ton code, tu écris init(from:). Cas typique : aplatir une structure imbriquée.
Tu veux un Product plat (amount et currency directement), pas un sous-struct.
nestedContainer descend dans l'objet imbriqué. C'est l'outil quand l'API impose une forme que tu ne veux pas reproduire dans ton modèle.
7. DTO vs Model : sépare le réseau du domaine
Erreur fréquente en mission : utiliser le type décodé directement partout dans l'app, vues comprises. Problème : ton modèle métier devient prisonnier du JSON. L'API change une clé, et toute l'app casse.
Solution propre : un DTO (Data Transfer Object) qui colle au JSON, et un Model propre pour le domaine, reliés par un mapping.
Dans l'APIClient :
Bénéfices : tes vues et ViewModels (@Observable) ne connaissent que User. Le DTO absorbe les bizarreries du JSON. Tu peux même rendre le Model non-Codable du tout — il n'a aucune raison de l'être.
8. DecodingError lisible en debug
Quand ça plante, le message brut est cryptique. Centralise un log clair dans ton client réseau.
Les deux erreurs que tu verras 90 % du temps :
keyNotFound: une clé non-optionnelle manque → rends-laOptionalou ajoute un défaut.typeMismatch: l'API renvoie"42"(String) là où tu attendsInt, ou un objet là où tu attends un tableau.
context.codingPath te donne le chemin exact dans le JSON où ça casse. C'est ton premier réflexe de debug.
Points à connaître
- Dès que tu écris un
CodingKeys, toutes les propriétés décodées doivent y figurer, pas seulement celles renommées — sinon elles ne sont plus décodées. .convertFromSnakeCaseet tesCodingKeysse cumulent : la stratégie agit d'abord, leCodingKeysraffine les cas irréguliers (avatar_url→avatarURL).- La valeur par défaut d'une propriété (
var x = true) est ignorée au décodage : pour un défaut sur clé absente, c'estdecodeIfPresent(...) ?? défaut. .iso8601ne gère pas les millisecondes : si l'API en envoie, passe en.custom. Et toujoursLocale(identifier: "en_US_POSIX")pour lesDateFormattermaison.- Ne décode pas directement vers tes modèles de domaine : DTO
Decodable+toModel(), ça isole l'app des changements d'API.
Exercice
Décode ce JSON snake_case vers un struct Article propre (camelCase, publishedAt: Date, summary optionnel avec défaut "Pas de résumé").
Contraintes : utilise .convertFromSnakeCase et .dateDecodingStrategy = .iso8601 sur un décodeur partagé, et gère summary (ici absent) sans crasher. Vérifie en imprimant l'objet décodé.
Question d'entretien
« Comment gérer un JSON dont les clés ne matchent pas tes propriétés ? »
Trois niveaux selon le cas. Si toute l'API est en snake_case, je pose
decoder.keyDecodingStrategy = .convertFromSnakeCaseune fois sur un décodeur partagé. Pour des clés irrégulières au cas par cas, je déclare un enumCodingKeys: String, CodingKeyavec la valeur brute (case id = "user_id"). Et quand la forme du JSON diffère vraiment de mon modèle — imbrication, champs à fusionner — j'écris uninit(from:)custom aveccontainer/nestedContainer. En plus, je sépare souvent un DTO qui épouse le JSON d'un modèle de domaine propre, avec untoModel().
« Différence entre decode et decodeIfPresent ? »
decodethrow si la clé est absente ounull.decodeIfPresentrenvoienildans ces cas sans throw — je l'utilise dans uninit(from:)pour appliquer une valeur par défaut avec?? défaut, parce que la valeur par défaut d'une propriété Swift est ignorée au décodage.
« Pourquoi un décodeur partagé plutôt qu'un par appel ? »
Une seule source de vérité pour les stratégies (snake_case, dates), pas de config dupliquée ni oubliée, et c'est un objet léger qu'on réutilise sans coût. Je le mets en
static letdans un namespace ou je l'injecte dans l'APIClient.
Résumé
Codable = Decodable & Encodable; déclare justeDecodablequand tu ne fais que lire.CodingKeyspour renommer,.convertFromSnakeCasepour le snake_case global — les deux se cumulent.- Pose un
JSONDecoder/JSONEncoderconfiguré une fois, partagé (snake_case +.iso8601). - Dates :
.iso8601pour le standard,.custompour les millisecondes ou les formats maison (en_US_POSIXobligatoire). - Champs absents :
Optionalpour dunil,decodeIfPresent ?? défautpour une valeur par défaut. init(from:)+nestedContainerquand le JSON ne colle pas à ton modèle.- Sépare DTO (
Decodable) et Model de domaine viatoModel()en extension. - En debug, catch les cas
DecodingError(keyNotFound,typeMismatch) et liscontext.codingPath.