JSON est plus simple que tu ne le crois, et c'est le problème
Publié le 08/08/2025 · 20 min de lecture · Outils pour développeurs
Daniel Okonkwo — Développeur front-end et rédacteur Tech chez OneKitly
Performance web · Formats de fichiers
Vérifié à partir de 7 sources
La grammaire de JSON tient sur une page, et c'est précisément pour cela qu'elle fuit. ECMA-404 et la RFC 8259 définissent six sortes de valeurs — objet, tableau, chaîne, nombre, true ou false, null — et rien d'autre. Il n'y a pas de type entier : JSON a un seul type numérique, qu'un analyseur JavaScript pose sur un double IEEE 754, si bien que 1234567890123456789 revient sous la forme du double 1234567890123456768, soit une erreur de 21, tandis que l'analyseur de Python le rend exact. Mêmes octets, deux valeurs différentes. Il n'y a pas de type date : un horodatage est une chaîne dont le format n'est qu'une convention. NaN et Infinity ne s'écrivent pas — JSON.stringify les transforme en null et JSON.parse rejette les littéraux, alors que le module json de Python les émet par défaut et produit des documents qui ne sont pas du JSON. Le zéro négatif survit de façon asymétrique : analyser -0 donne -0, mais sérialiser -0 donne 0. Les clés dupliquées sont légales dans la grammaire, la RFC 8259 dit seulement que le résultat est imprévisible, et tous les analyseurs courants gardent silencieusement la dernière. Les commentaires ne figurent pas du tout dans la grammaire. Un JSON Schema généré fixe la forme, jamais le sens, et celui déduit d'un seul échantillon surapprend gravement. Enfin, l'ordre des clés est un fait au niveau des octets : intervertissez-en deux et la charge utile ne se hache plus pareil, ce qui casse les signatures.
JSON n'a ni type entier, ni type date, ni commentaires, ni schéma. Chacune de ces absences produit un bogue précis : un identifiant à 19 chiffres revient faussé de 21, un horodatage devient une chaîne sur laquelle personne ne s'est accordé, NaN ne s'écrit pas, et les clés dupliquées sont légales. Le tout exécuté, dans deux langages.
Six sortes de valeurs, et aucune n'est celle qu'il te fallait
JSON est spécifié deux fois, par l'ECMA-404 et par la RFC 8259, et les deux documents sont courts parce qu'il y a très peu à dire. Une valeur est un objet, un tableau, une chaîne, un nombre, le littéral true, le littéral false ou le littéral null. Voilà tout le système de types. Tout le reste que tu crois trouver dans JSON a été ajouté par ton langage à l'entrée ou à la sortie.
Compte les absences. Pas de type entier, seulement une production numérique unique qu'un analyseur doit projeter sur le type numérique dont il dispose. Pas de date, pas d'heure, pas de durée. Pas de binaire — tu obtiens du base64 dans une chaîne, ce qui te coûte un tiers d'octets en plus. Pas de commentaires. Pas d'énumérations, pas d'intervalles, pas de champs obligatoires, aucun schéma. Aucune garantie sur l'ordre des clés. Aucun moyen d'exprimer une référence vers une autre partie du même document : un graphe doit être aplati à la main.
Rien de tout cela n'est un défaut de conception. JSON a été extrait d'une syntaxe littérale JavaScript pour déplacer des données entre deux programmes qui s'accordaient déjà sur leur sens, et il fait ce travail à une taille et une vitesse que rien n'a surpassées. Le défaut apparaît quand un format qui ne transporte aucun sens est utilisé comme s'il en transportait. Tout ce qui suit est une instance de cette erreur.
Le nombre qui revient faux
La grammaire numérique de JSON accepte n'importe quel décimal que tu peux écrire. La RFC 8259 avertit que les implémentations varient et recommande de rester dans la double précision IEEE 754, ce qui signifie en pratique des entiers jusqu'à 2^53 − 1 = 9 007 199 254 740 991. Au-delà, les doubles ne peuvent plus représenter des entiers consécutifs, et un analyseur JavaScript n'a nulle part ailleurs où ranger la valeur.
Exécute-le. JSON.parse('9007199254740993') renvoie 9007199254740992 — l'entrée était impaire, la sortie est paire, et aucune erreur n'a été levée. Prends un identifiant réaliste à 19 chiffres du genre de ceux que distribuent les plateformes sociales et les messageries : JSON.parse('{"id": 1234567890123456789}') stocke le double 1234567890123456768, que JavaScript affiche ensuite comme 1234567890123456800, car c'est le plus court décimal qui revient au même double. L'identifiant reçu diffère de l'identifiant stocké de 21, et de l'identifiant affiché de 32. Un id plus court à 18 chiffres, 175928847299117063, revient en 175928847299117060.
Donne maintenant les octets identiques à Python 3.13 : json.loads renvoie exactement 1234567890123456789, en tant qu'entier Python, car l'analyseur de Python projette la production numérique JSON sur un entier de précision arbitraire quand il n'y a pas de partie fractionnaire. Un document, deux langages, deux valeurs différentes, et les deux analyseurs sont conformes. Voilà tout le problème en une ligne : JSON n'indique pas à l'analyseur quel type numérique utiliser, alors l'analyseur décide, et la décision n'est pas la même partout.
Le même trou avale la distinction entier / décimal dans l'autre sens. JSON.stringify(1.0) produit les deux caractères "1", si bien qu'un champ que ta base a déclaré décimal arrive sous une forme qu'un générateur de schéma étiquettera entier. Il n'existe aucun moyen d'écrire 1.0 en JSON et de le voir rester 1.0 après un aller-retour JavaScript. Le correctif pour les identifiants est brutal et universel : envoie-les sous forme de chaînes. Les plateformes mordues tôt livrent les deux champs — un id numérique et un id textuel — précisément parce qu'elles ne pouvaient pas corriger leurs clients.
Dates, NaN, Infinity et un signe moins qui disparaît
JSON.stringify(new Date(...)) produit "2026-08-28T14:30:00.000Z", ce qui donne l'impression que JSON comprend les dates. Non. ECMAScript définit Date.prototype.toJSON, et JSON.stringify l'appelle ; le résultat est une chaîne ordinaire. Reparse-la et typeof donne "string". Le format se trouve être celui que la RFC 3339 profile à partir d'ISO 8601, mais rien dans JSON ne l'exige, et dès qu'un second service sérialise avec une autre bibliothèque tu obtiens une date d'une autre forme — un entier epoch, une heure locale sans décalage, un enrobage "/Date(1234567890)/" venu d'une pile plus ancienne. Chacune est du JSON tout aussi valide et tout aussi impossible à interpréter sans accord préalable.
NaN et les deux infinis ne sont pas dans la grammaire : ils ne peuvent donc pas être écrits du tout. JSON.stringify({a: NaN, b: Infinity, c: -Infinity}) renvoie {"a":null,"b":null,"c":null} — trois valeurs flottantes distinctes réduites à un seul null, en silence. JSON.parse('{"a":NaN}') lève une SyntaxError. Dans un tableau, undefined, les fonctions et les symboles deviennent également null ; comme valeurs d'objet, ils sont purement supprimés, si bien que {a: undefined, b: 1} se sérialise en {"b":1} et qu'une clé disparaît tout simplement.
Python fait pire et plus intéressant : json.dumps({'a': nan, 'b': inf}) émet par défaut {"a": NaN, "b": Infinity}, et json.loads le relit sans broncher. Cette sortie n'est pas du JSON. Elle traversera tes propres services Python sans dommage et échouera dès qu'elle atteindra un analyseur conforme dans un autre langage, c'est-à-dire généralement le navigateur, c'est-à-dire généralement la production. L'option existe — allow_nan=False lève une exception — et presque personne ne la met.
Le zéro négatif est le plus petit et le plus étrange de la série. L'IEEE 754 a deux zéros, et ils comptent là où un signe porte une information — un taux de variation, un sens d'arrondi, un solde arrivé exactement à rien par en dessous. JSON.parse('-0') renvoie -0 : Object.is(JSON.parse('-0'), -0) vaut true, et 1 divisé par lui donne −Infinity. Mais JSON.stringify(-0) renvoie le seul caractère 0. La valeur survit donc dans un sens et pas dans l'autre, et un aller-retour complet fait basculer 1 ÷ x de −Infinity à +Infinity sans le moindre avertissement.
Les clés dupliquées sont légales, et chaque analyseur en choisit une en silence
La grammaire JSON définit un objet comme une suite de paires nom-valeur séparées par des virgules. Elle ne dit pas que les noms doivent différer. La RFC 8259 traite le sujet en prose plutôt que dans la grammaire : les noms DEVRAIENT être uniques, et elle avertit que les implémentations confrontées à un doublon se comportent différemment — certaines prennent la dernière, certaines la première, certaines signalent une erreur. Ce DEVRAIENT est le mot le plus faible que la RFC pouvait employer, et il signifie que tous les analyseurs que tu croiseras acceptent le document.
En pratique, le courant dominant a convergé. JSON.parse('{"role":"admin","role":"user"}') renvoie {"role":"user"} sous Node, et json.loads de Python renvoie la même chose. Le dernier gagne, sans avertissement, sans moyen de détecter après coup que le document en contenait deux. L'information qu'un doublon existait est détruite par l'analyse elle-même, et c'est ce qui rend le débogage difficile : quand ton code voit l'objet, la preuve a disparu.
La conséquence à prendre au sérieux, c'est qu'un document peut vouloir dire une chose pour le composant qui le vérifie et une autre pour celui qui agit dessus, si ces deux composants utilisent des analyseurs qui divergent — ou si l'un inspecte le texte brut et l'autre l'objet analysé. La forme générale de ce danger et la règle qui en découle sont traitées dans l'article de ce site sur la vérification des jetons signés : valide et agis sur la même représentation analysée, jamais sur deux. La parade ici est plus simple. Rejette les documents à noms dupliqués dès l'entrée, avant que quoi que ce soit d'autre les regarde ; un analyseur en flux ou une passe de tokenisation préalable voit le doublon que JSON.parse jette.
Ce qu'un schéma généré apporte, et où il surapprend
JSON Schema comble le plus grand trou : c'est un vocabulaire pour dire quelles clés doivent exister, quel type a chaque valeur, quelles valeurs sont permises et jusqu'où va l'imbrication. Un validateur transforme un document informe en un oui ou un non à la frontière de ton système, ce qui vaut beaucoup. Générer un premier jet à partir d'un échantillon dont tu disposes déjà est le chemin le plus rapide, et c'est ce que fait le générateur de schémas de ce site.
Le piège, c'est qu'un schéma déduit d'un seul document décrit ce document, pas la famille à laquelle il appartient. Prends un échantillon d'apparence anodine : un objet avec un id entier valant 42, un nom, un tableau d'un seul tag textuel, un score entier de 10, un manager qui se trouve être null, et un booléen. Un générateur naïf produit le type entier pour id et score, le type null pour manager, un tableau de chaînes pour tags, toutes les clés dans required et additionalProperties à false.
Valide maintenant cinq documents ultérieurs parfaitement légitimes contre lui. Un score arrivant à 10.5 est rejeté, parce que l'échantillon était entier. Un manager enfin renseigné par un objet est rejeté, parce que l'échantillon valait null. Un document qui omet un champ facultatif est rejeté, parce que le générateur a mis toutes les clés dans required. Un document avec un nouveau champ email est rejeté, parce que additionalProperties valait false. Un tableau tags contenant un nombre est rejeté. Cinq sur cinq, et chacun est un enregistrement réel que ton système aurait dû accepter.
L'autre moitié de la leçon, c'est ce que le même schéma accepte volontiers : un enregistrement avec un nom vide et un score de −999 passe tous les contrôles, parce que JSON Schema valide la forme et jamais le sens. Rien dans le vocabulaire ne sait qu'un nom devrait être non vide ni qu'un score a un plancher. Sers-toi donc de la génération comme d'un premier jet, puis édite-le à la main : élargis integer en number partout où un décimal est possible, remplace un type null par une union nullable, réduis required aux champs réellement obligatoires, laisse additionalProperties ouvert sauf si tu verrouilles délibérément le contrat, et ajoute les contraintes minLength, minimum et enum qui portent tes vraies règles métier.
L'ordre des clés, et la signature qui cesse de correspondre
Les objets JSON sont non ordonnés en tant que modèle de données, mais un document JSON est une suite d'octets et les octets ont un ordre. JSON.stringify émet les clés textuelles dans l'ordre d'insertion — à une exception près qui surprend. ECMAScript place d'abord les clés d'indice entier, en ordre croissant, avant toute clé textuelle. Construis un objet en affectant z, puis user_2, puis "2", puis user_1, puis "1" : stringify renvoie {"1":5,"2":3,"z":1,"user_2":2,"user_1":4}. Les deux clés d'allure numérique ont sauté en tête et se sont triées numériquement ; le reste est resté dans l'ordre écrit. L'analyse fait pareil : un document reçu dans un ordre ressort de JSON.parse dans un autre.
Cela devient un incident de production dès que tu haches une charge utile. Deux services décrivent le même virement de 100 € : l'un écrit {"amount":100,"currency":"EUR","to":"acct_9"} et l'autre les mêmes trois champs en commençant par "to". Les objets sont profondément égaux. Les empreintes SHA-256 valent 1648f3b9016a5b95… et bc654befe505d093…, et un HMAC calculé sur chacune diffère dès le premier octet. Le destinataire rejette une requête qui est, sémantiquement, exactement celle qu'il attendait.
Trier les clés avant de sérialiser corrige ce cas précis — les deux objets se canonisent vers la forme commençant par amount et les empreintes coïncident. Mais trier seul ne constitue pas une forme canonique, car la même valeur peut encore s'écrire de plusieurs façons : "é" et "\u00e9" sont la même chaîne et des octets différents, 1e21 et 1000000000000000000000 sont le même nombre, et un sérialiseur peut ou non échapper la barre oblique. La RFC 8785, JSON Canonicalization Scheme, est la réponse normalisée : elle fixe l'ordre des clés par unité de code UTF-16, impose le formatage des nombres selon les règles ECMAScript et définit exactement quels caractères sont échappés. Si tu peux éviter le problème, évite-le : signe et vérifie les octets exacts reçus, et ne re-sérialise jamais un document que tu te apprêtes à contrôler.
La liste pratique
Envoie chaque identifiant sous forme de chaîne, quel que soit son type dans ta base. Conviens par écrit d'un seul format d'horodatage — la RFC 3339 avec décalage explicite est le moins discuté — et rejette tout le reste à la frontière plutôt que de deviner. Décide à l'avance de ce que signifie une valeur manquante, et choisis soit null soit l'absence, pas les deux. Ne laisse jamais un NaN ou un infini atteindre un sérialiseur : convertis-le en null, en chaîne ou en erreur, délibérément, là où le calcul a lieu.
Rejette les noms dupliqués à l'entrée. Génère un schéma pour gagner du temps de frappe, puis édite-le avant de lui faire confiance. Canonise, ou signe les octets bruts, jamais un objet re-sérialisé. Et garde les fichiers de configuration, là où les humains ont besoin de commentaires et de virgules finales, dans un format qui les possède — c'est le sujet de l'article suivant.
| Dans le document | Node renvoie | Python renvoie | Conséquence |
|---|---|---|---|
| 9007199254740993 | 9007199254740992 | 9007199254740993 (exact) | Un nombre impair devient pair, sans erreur |
| 1234567890123456789 | 1234567890123456768, affiché 1234567890123456800 | 1234567890123456789 (exact) | Identifiant faussé de 21 ; les deux services divergent |
| 1.0 | 1, et JSON.stringify le réécrit "1" | 1.0 en flottant, réécrit 1.0 | Distinction décimal/entier perdue dans un seul langage |
| Une Date sérialisée, "2026-08-28T14:30:00.000Z" | Une chaîne (typeof vaut "string") | Une chaîne | Il n'y a pas de type date ; le format est une convention |
| NaN écrit en littéral | SyntaxError — rejeté | nan — accepté, et émis par défaut | Python écrit des documents qui ne sont pas du JSON |
| -0 sérialisé depuis un programme | Écrit 0 ; le signe a disparu | Écrit -0.0 ; le signe survit | 1 ÷ x bascule de −Infinity à +Infinity |
| {"role":"admin","role":"user"} | role = user (le dernier gagne) | role = user (le dernier gagne) | Grammaire légale, imprévisible selon la RFC 8259 |
Questions fréquentes
- Comment faire passer un identifiant 64 bits dans du JSON sans perdre de chiffres ?
- Envoie-le sous forme de chaîne. C'est le seul correctif qui marche partout, et c'est pourquoi les plateformes mordues les premières publient deux champs — un id numérique et une version textuelle du même id — plutôt que de casser leurs clients. Un reviver de JSON.parse ne t'aidera pas : le reviver s'exécute après que le tokeniseur a déjà produit le double, si bien que lorsque ton rappel voit la valeur, les chiffres ont disparu. Des analyseurs compatibles bigint existent et fonctionnent, parce qu'ils lisent le texte du jeton et décident eux-mêmes du type, mais ils changent ce que reçoit ton code et chaque comparaison, JSON.stringify et opération arithmétique en aval doit être auditée. Si tu ne peux pas changer le producteur, détecte au moins les dégâts : un entier dont la valeur absolue dépasse Number.MAX_SAFE_INTEGER, 9007199254740991, n'est plus digne de confiance, et un aller-retour par String(BigInt(x)) comparé au jeton brut te dira s'il a survécu. Et quand tu passeras aux chaînes, rappelle-toi qu'un identifiant textuel se trie lexicographiquement : "10" vient avant "9", donc tout ordre sur lequel tu comptais doit migrer vers un champ numérique séparé ou vers la base.
- Les clés dupliquées sont-elles vraiment du JSON valide ?
- Oui, grammaticalement. Ni l'ECMA-404 ni la grammaire de la RFC 8259 n'interdisent un nom répété : un document qui en contient s'analyse. La RFC 8259 ajoute une exigence en prose au niveau DEVRAIT — les noms devraient être uniques — et prévient que les implémentations divergent quand ils ne le sont pas, en listant trois comportements plausibles : garder le dernier, garder le premier, ou signaler une erreur. Mesuré ici, JSON.parse de Node et json.loads de Python gardent tous deux le dernier, si bien que {"role":"admin","role":"user"} donne le rôle user dans les deux. Comme l'analyse elle-même détruit la preuve, tu ne peux pas détecter le doublon depuis l'objet résultant, et aucune validation ultérieure ne le trouvera. La règle pratique est de rejeter à l'entrée : soit un analyseur en flux ou événementiel qui signale chaque nom à mesure, soit une passe de tokenisation bon marché qui compte les noms par objet, et tu refuses la requête. Un schéma ne le fera pas pour toi — JSON Schema opère sur l'instance analysée, à un stade où le doublon est déjà résolu.
- JSON n'a pas de commentaires. Qu'utiliser pour les fichiers de configuration ?
- Les commentaires ont été écartés délibérément, au motif que les gens y mettraient des directives d'analyse. Les virgules finales, les chaînes à guillemets simples et les clés non quotées sont absentes pour la même raison : la grammaire est petite pour que toutes les implémentations s'accordent. C'est une bonne propriété pour des données en transit et une propriété désastreuse pour un fichier qu'un humain maintient. Le partage honnête consiste à utiliser du JSON strict pour tout ce qu'une machine produit ou transmet, et quelque chose de plus accueillant pour tout ce qu'une personne édite. JSONC — JSON avec commentaires — est ce qu'acceptent plusieurs éditeurs et chaînes d'outils, et c'est le plus petit pas de côté. JSON5 ajoute les virgules finales, les clés non quotées, les guillemets simples, les nombres hexadécimaux et les littéraux NaN et Infinity qui manquent à JSON. TOML est conçu spécifiquement pour la configuration et a de vraies dates. YAML est le plus déployé et fait l'objet de l'article suivant de cette série, y compris les manières dont son inférence de types te surprendra. La seule chose à ne pas faire est une clé "_comment" : c'est légal, cela survit aux allers-retours, et cela survit aussi jusque dans ce que tu sérialises ensuite, là où personne ne l'attend.
- Un schéma généré remplace-t-il une validation écrite à la main ?
- Non, pour deux raisons distinctes. D'abord, la génération surapprend : mesuré plus haut, un schéma déduit d'un seul enregistrement a rejeté cinq enregistrements ultérieurs légitimes sur cinq — un décimal là où l'échantillon avait un entier, un champ renseigné là où l'échantillon valait null, un facultatif absent, un champ ajouté et un tableau de types mixtes. Chacun est une évolution normale d'une charge utile réelle. Ensuite, JSON Schema valide la forme et non le sens, par conception : le même schéma a accepté sans broncher un enregistrement au nom vide et au score de −999. Ce à quoi la génération sert vraiment, c'est la partie fastidieuse : énumérer cinquante clés et leurs types sans faute de frappe, et te donner un fichier de départ qui s'analyse déjà. Traite la sortie comme un brouillon et fais quatre corrections avant de lui faire confiance — élargis integer en number partout où un décimal peut apparaître, fais des champs nullables une union plutôt que le type null, élague required à ce qui est réellement obligatoire, et décide consciemment si additionalProperties doit valoir false. Ajoute ensuite les contraintes qui portent les règles métier, car ce sont exactement celles qu'aucun générateur ne peut déduire des données.
- Pourquoi deux services calculent-ils des empreintes différentes de la même charge utile ?
- Parce qu'une empreinte porte sur des octets et que les deux services ont produit des octets différents pour la même valeur. Trois choses varient indépendamment. L'ordre des clés est le coupable habituel : deux objets profondément égaux se sérialisent différemment si leurs clés ont été insérées dans un ordre différent, et ECMAScript hisse en outre les clés d'allure d'indice entier en tête, en ordre numérique croissant, si bien que "2" et "10" passent devant toutes les autres, où que tu les aies écrites. L'échappement des chaînes est le deuxième : "é" écrit directement et écrit \u00e9 sont la même chaîne et des octets différents, et les sérialiseurs divergent sur l'échappement de la barre oblique et des deux séparateurs de ligne U+2028 et U+2029. Le formatage des nombres est le troisième : 1e21 et sa forme décimale longue désignent le même double, et 1.0 se sérialise en 1. Le bon correctif dépend de ta position. Si tu vérifies quelque chose de reçu, hache les octets exacts arrivés et ne les re-sérialise jamais, ce qui contourne les trois problèmes d'un coup. Si tu dois hacher une valeur construite par toi, utilise une forme canonique définie : la RFC 8785 en spécifie une, qui fixe ensemble l'ordre des clés, le formatage des nombres et l'échappement, et des bibliothèques l'implémentent dans la plupart des langages.
- JSON.parse est-il sûr sur une entrée non fiable ?
- Structurellement, oui, et bien plus sûr que l'eval qu'il a remplacé : la grammaire ne contient aucune construction exécutable, donc un document analysé ne peut pas exécuter de code. Deux inquiétudes reviennent, toutes deux moins alarmantes que leur réputation. La pollution de prototype n'est pas causée par JSON.parse — la spécification impose de créer des propriétés de données, si bien que JSON.parse('{"__proto__": {"admin": true}}') donne un objet avec une propriété propre ordinaire nommée __proto__ et laisse Object.prototype intact ; vérifié ici, ({}).admin vaut toujours undefined. La pollution survient ensuite, dans une fusion récursive naïve ou une boucle d'affectation non protégée qui parcourt ces clés : c'est là qu'il faut la garde. Le débordement de pile par imbrication profonde est aussi largement historique : l'analyseur de V8 est itératif, et un million de niveaux de tableaux imbriqués se sont analysés sans erreur sous Node 26. Ce qui mérite vraiment d'être limité, c'est la taille et le temps. Un analyseur doit lire tout le document avant de produire quoi que ce soit : un corps non borné signifie une mémoire non bornée, et les clés dupliquées, les nombres démesurés et les champs inattendus restent à rejeter à la frontière. Plafonne le corps de la requête, puis valide.
Articles qui pourraient t'intéresser
Tous les guides →Outils similaires
Sources
- Ecma International — ECMA-404: The JSON Data Interchange Syntax
- IETF — RFC 8259 — The JavaScript Object Notation (JSON) Data Interchange Format
- JSON Schema — JSON Schema specification — core and validation vocabularies
- Ecma International / TC39 — ECMA-262 — JSON.parse, JSON.stringify, Date.prototype.toJSON and OrdinaryOwnPropertyKeys
- IETF — RFC 8785 — JSON Canonicalization Scheme (JCS)
- IETF — RFC 3339 — Date and Time on the Internet: Timestamps
- Python Software Foundation — json — JSON encoder and decoder (allow_nan, parse_int, and the non-standard NaN/Infinity extension)
Tu as repéré une erreur dans cet article ?