JSON Toolkit

Six types, précision des nombres et pièges d'encodage

L’essentiel : JSON ne compte que six types, mais chacun peut remodeler vos données sans bruit. Cet article traite d'un seul tenant les pièges des nombres, des chaînes et de null.

Débutant~12 min de lecturePublié le 2026-10-30Mis à jour le 2026-10-30
Illustration de l'article Six types, précision des nombres et pièges d'encodage

L'essentiel : vérifiez d'abord si des champs d'identifiant ou de montant dépassent 2^53 - 1 ou exigent beaucoup de décimales, puis si les limites de chaîne comptent des points de code ou des unités UTF-16 ; transportez toute valeur au-delà de la plage entière sûre sous forme de chaîne au lieu de compter sur le parseur pour préserver la précision.

JSON a moins de types que la plupart des gens ne le pensent, et plus de pièges : les nombres dérivent, les chaînes cassent, et null n'est pas la même chose qu'une clé absente.

1. Le paysage des types

Type Exemple littéral Contrainte en JSON Mapping courant
Objet {"a": 1} Les clés doivent être des chaînes, les valeurs peuvent être quelconques Object / Map / struct
Tableau [1, "a", null] Les éléments peuvent être hétérogènes Array / List / slice
Chaîne "text" UTF-8, guillemets doubles String
Nombre 1, -1.5, 1e9 Pas de zéros initiaux, pas de +, pas de NaN double / Decimal
Booléen true / false Minuscules bool
null null La seule valeur nulle null / Option::None

Notez les deux choses qui n'existent pas : il n'y a pas de type date, et il n'y a pas de type entier. Toutes les dates sont des chaînes, et tous les entiers et flottants partagent le même type « nombre ».

2. Nombres : la double précision est la réponse par défaut

2.1 La frontière de l'entier sûr

Le plus grand entier que le double précision IEEE 754 peut représenter exactement est 2^53 - 1 = 9007199254740991. Au-delà, des trous apparaissent entre entiers adjacents :

JSON.parse('{"id": 9007199254740993}').id
// 9007199254740992

JSON.stringify({ id: 9007199254740993 })
// '{"id":9007199254740992}'

Ce n'est pas un problème JSON ; c'est un problème de l'étape « texte → double précision ». Tout pipeline avec du JavaScript au milieu (passerelle, BFF, frontend, service Node) y sera exposé.

Remèdes (classés par ordre de recommandation) :

  1. Utilisez des chaînes pour les identifiants tout au long du pipeline : {"id": "9007199254740993"}, le moins de tracas de tous ;
  2. Parsez avec des types haute précision côté serveur : BigInteger / BigDecimal de Java, json.Number de Go, decimal.Decimal de Python ;
  3. Côté frontend, utilisez le reviver de JSON.parse ou un parseur qui prend en charge les grands nombres (comme json-bigint) pour transformer le champ en BigInt, mais notez que BigInt ne peut pas être passé directement à JSON.stringify.

2.2 Les décimales ne sont pas des valeurs exactes

0.1 + 0.2            // 0.30000000000000004
JSON.stringify(0.1 + 0.2)
// "0.30000000000000004"

Représentez toujours les montants monétaires en entiers dans la plus petite unité (centimes) ou en chaînes ; ne les accumulez jamais en flottant.

2.3 Pièges des nombres à la sérialisation

JSON.stringify({ a: 1e21 })        // {"a":1e+21}
JSON.stringify({ a: -0 })          // {"a":0}
JSON.stringify({ a: Infinity })    // {"a":null}
JSON.stringify({ a: NaN })         // {"a":null}
JSON.stringify({ a: 10n })         // TypeError: Do not know how to serialize a BigInt

Infinity et NaN sont transformés silencieusement en null, ce qui est la catégorie la plus difficile à traquer de « la donnée a simplement disparu ».

2.4 Comportement par défaut selon les langages

Langage Type numérique par défaut Cas nécessitant un traitement explicite
JavaScript double Grands entiers, sérialisation de BigInt
Go float64 (encoding/json) Utilisez json.Number ou Decoder.UseNumber() pour préserver la précision
Java (Jackson) int/long/double/BigDecimal inféré depuis le littéral Forcez DeserializationFeature.USE_BIG_DECIMAL_FOR_FLOATS
Python int (précision arbitraire) / float Utilisez parse_float=Decimal pour préserver la précision décimale
Rust (serde_json) i64/u64/f64 La précision arbitraire exige d'activer arbitrary_precision

3. Chaînes : un flux d'octets UTF-8 vu à travers UTF-16

3.1 La longueur n'est pas le nombre de caractères

En JavaScript, "🎉".length vaut 2, parce que les emoji vivent dans les plans supplémentaires et sont représentés par une paire de substituts. Si le serveur limite la longueur par « nombre de caractères » tandis que le frontend valide par length, les deux côtés seront garantis en désaccord.

'🎉'.length                 // 2
Array.from('🎉').length     // 1
[...'👨‍👩‍👧'].length           // 5 (including zero-width joiners)

Quand il faut un comptage rigoureux, utilisez Intl.Segmenter (par grappe de graphèmes) ou comptez les points de code Unicode côté backend, et énoncez clairement la définition dans la documentation de l'API.

3.2 Substituts isolés

Un substitut haut isolé comme "\ud800" est grammaticalement du JSON valide (\uXXXX accepte quatre chiffres hexadécimaux quelconques), mais ce n'est pas du texte Unicode valide. Depuis ES2019, JSON.stringify échappe les substituts isolés en \ud800, garantissant que la sortie est « bien formée ». Lors de transferts entre langages, ces valeurs seront rejetées si l'autre côté valide Unicode strictement.

3.3 \u2028 et \u2029

Les séparateurs de ligne et de paragraphe sont valides en JSON, mais les intégrer directement dans du code source JavaScript ou une balise <script> provoque une erreur de syntaxe. Quand vous intégrez du JSON dans du HTML, échappez-les toujours ou passez à un parsing depuis une chaîne avec JSON.parse.

3.4 Trois sources de texte chinois brouillé

  1. Le fichier lui-même est en GBK et est lu en UTF-8 ;
  2. L'en-tête de réponse déclare charset=gbk, mais le contenu est en réalité de l'UTF-8 ;
  3. Il traverse un middleware qui « décode en chaîne puis réencode », donc le texte chinois est transcodé une seconde fois.

Ordre d'investigation : regardez d'abord les octets (xxd), puis l'en-tête de réponse, et enfin le middleware.

4. null contre « cette clé n'existe pas »

En JSON ces deux sont différents :

{"nickname": null}
{}
  • Le premier signifie « le champ existe, avec une valeur vide » ;
  • Le second signifie « le champ n'existe pas ».

En TypeScript cela devient la différence entre string | null et nickname?: string ; en Java la différence entre « null explicite » et « valeur par défaut ». Sans convention convenu, ces divergences apparaissent :

// Frontend A's approach
if (data.nickname === undefined) { /* treat it as having no nickname */ }
// Frontend B's approach
if (!data.nickname) { /* handle null and the empty string together */ }

Recommandation : la documentation d'API devrait énoncer « utilisez null uniformément quand une valeur est absente, n'omettez jamais la clé », ou l'inverse — mais choisissez-en un seul ; lors d'une mise à jour d'API, utilisez la sémantique PATCH pour éviter l'ambiguïté « n'a pas été envoyé, ou envoyé comme null ».

5. Tableaux et objets

5.1 Les tableaux ne sont pas « homogènes »

[1, "two", {"three": 3}, null]

Grammaticalement valide, mais la définition de type devient très difficile à écrire. Dans les API, essayez de garder les éléments de tableau homogènes ; pour les cas hétérogènes, passez à un tableau d'objets avec un champ discriminant type.

5.2 Les clés d'objet sont non ordonnées, et pourtant beaucoup dépendent de l'ordre

La spécification dit qu'un objet est « une collection non ordonnée de paires nom/valeur ». En pratique V8 préserve l'ordre d'insertion (sauf les clés entières, qui sont triées). Bâtir de la logique sur l'ordre des clés (prendre « la première clé », par exemple) signifie dépendre d'un détail d'implémentation.

5.3 Les tableaux creux et les trous n'existent pas

Le [1, , 3] de JavaScript se sérialise en [1,null,3] — le trou devient null. JSON n'a aucun concept d'« emplacement ».

6. Une liste de vérification actionnable

  • [ ] Tous les identifiants, montants et entiers surdimensionnés sont explicitement des chaînes dans le contrat d'API ;
  • [ ] La documentation d'API énonce la sémantique de null contre un champ omis ;
  • [ ] Les limites de longueur de chaîne précisent si elles comptent des points de code ou des unités UTF-16 ;
  • [ ] Filtrez Infinity / NaN / undefined avant sérialisation, ou convertissez-les explicitement en null ;
  • [ ] Le JSON intégré dans du HTML est échappé ;
  • [ ] Frontend et backend ont chacun un cas de test de contrat couvrant « grand entier + emoji + null ».

FAQ

Comment gérer les entiers au-delà de 2^53 - 1 ?

L'approche la moins pénible est d'envoyer des champs comme les identifiants sous forme de chaînes tout au long du pipeline, par exemple {"id": "9007199254740993"}. Côté serveur, vous pouvez préserver la précision avec BigInteger / BigDecimal de Java, json.Number de Go ou decimal.Decimal de Python. Côté frontend, un parseur comme json-bigint peut transformer le champ en BigInt, mais rappelez-vous que BigInt ne peut pas être passé directement à JSON.stringify.

Pourquoi Infinity et NaN deviennent-ils null après sérialisation ?

Le type nombre JSON n'accepte pas les littéraux non standard comme NaN et Infinity, donc JSON.stringify les convertit silencieusement en null sans aucune erreur. C'est la catégorie la plus difficile à traquer de « la donnée a simplement disparu ». Filtrez explicitement ces valeurs avant sérialisation, ou convertissez-les en null selon votre convention métier et journalisez-le.

Faut-il envoyer null ou omettre entièrement le champ ?

Les deux fonctionnent, mais la documentation d'API doit en choisir un et l'énoncer clairement, sinon chaque frontend supposera quelque chose de différent. Dans les exemples, {"nickname": null} et {} correspondent à string | null et nickname?: string en TypeScript. Utilisez la sémantique PATCH lors d'une mise à jour d'API pour éviter l'ambiguïté « n'a pas été envoyé, ou envoyé comme null ».