Aller au contenu
jsonbeautifiers
Français

Pourquoi vos identifiants JSON changent de valeur

Les nombres JSON sont sans borne. Les doubles IEEE 754 ne le sont pas. Tout le reste en découle.

Chaque affirmation de cette page est soit mesurée, soit sourcée. Quand elle n’est ni l’une ni l’autre, la page le dit.

Collez ceci dans une console de navigateur :

JSON.parse('{"id": 12345678901234567890}')
// { id: 12345678901234567000 }

Les trois derniers chiffres ont changé. Rien n’a été levé, rien n’a averti, et si cette valeur était un snowflake Twitter ou une clé primaire de base de données, vous avez désormais un autre enregistrement. C’est de loin la façon la plus courante dont JSON abîme des données en silence, et cela se produit là où on l’attend le moins : dans l’analyseur auquel on fait confiance.

Où se trouve réellement la frontière

La RFC 8259 ne pose aucune limite à la taille ni à la précision d’un nombre JSON. La grammaire autorise n’importe quel nombre de chiffres. Ainsi 12345678901234567890123456789 est un nombre JSON parfaitement valide, et un décimal avec deux cents chiffres après la virgule aussi.

JavaScript n’a qu’un seul type numérique pour ce qui concerne JSON : le flottant double précision IEEE 754. Un double dispose de 53 bits de mantisse, ce qui signifie qu’il peut représenter exactement tout entier jusqu’à 2^53-1 et ne peut pas représenter tous ceux au-delà. Cette valeur vaut 9007199254740991, et JavaScript l’expose sous le nom Number.MAX_SAFE_INTEGER.

Au-delà, les doubles deviennent clairsemés. L’écart entre entiers représentables est de 2 jusqu’à 2^54, puis 4, puis 8, doublant à chaque fois. Donc :

9007199254740992 === 9007199254740993   // true

Ces deux-là sont le même double. Il n’existe aucune configuration de bits pour l’impair, il est donc arrondi à son voisin pair. Votre identifiant n’a pas été corrompu par un bug ; il a atterri dans un trou de la droite numérique.

La RFC l’anticipe. La section 6 dit qu’un nombre est interopérable s’il fait l’aller-retour à travers IEEE 754 binary64, et note que les implémentations qui l’utilisent « en général… seront interopérables au sens où les implémentations s’accorderont exactement sur leurs valeurs numériques ». Le mot qui fait tout le travail ici est en général.

Il ne s’agit pas que d’entiers énormes

Les décimaux perdent en précision bien plus tôt et de façon bien moins visible :

0.1 + 0.2                    // 0.30000000000000004
JSON.parse('{"v": 1.005}')   // { v: 1.005 }, mais 1.005 * 100 vaut 100.49999999999999

Le bug monétaire classique. Un prix stocké en tant que 1.005 ne peut pas être représenté exactement en double, si bien que l’arrondir à deux décimales donne 1,00 au lieu de 1,01. C’est pourquoi les systèmes financiers stockent l’argent en unités mineures sous forme d’entiers, ou en chaînes décimales, et jamais en flottants JSON.

Il y en a un autre, plus discret. Un document JSON contenant 1.0 devient le nombre 1 en JavaScript, et le re-sérialiser produit 1. Le document a changé. Pour la plupart des usages cela n’a aucune importance ; pour un document que vous hachez, signez ou comparez, si.

Ce que fait chaque langage

Les comportements divergent plus qu’on ne l’imagine, et savoir de quel côté vous êtes détermine votre correctif.

Langage Par défaut, pour un grand entier Peut-on conserver les chiffres ?
JavaScript Arrondit au double le plus proche, en silence Non. JSON.parse n’offre aucun point d’accroche qui voie le texte d’origine
Python int à précision arbitraire, exact Oui, automatiquement. parse_int et parse_float reçoivent le texte brut
Go float64 par défaut Oui. Decoder.UseNumber() conserve le texte sous forme de json.Number
Java (Jackson) Integer, Long ou BigInteger selon le besoin Oui, et USE_BIG_INTEGER_FOR_INTS le force
Rust (serde_json) u64 / i64 / f64 Oui, avec la fonctionnalité arbitrary_precision
PHP int jusqu’à PHP_INT_MAX, puis float En partie. JSON_BIGINT_AS_STRING les garde en chaînes
C# long, decimal ou double selon l’analyseur Oui, System.Text.Json expose le texte brut

L’asymétrie est la partie dangereuse. Un service Python écrit un entier exact de 19 chiffres, un client JavaScript en lit un autre, et les deux systèmes sont en désaccord sur une valeur qu’aucun des deux n’a jamais journalisée.

Le problème propre à JavaScript

JavaScript fait bande à part parce que JSON.parse ne vous laisse aucun moyen d’intervenir. La fonction reviver s’exécute après que le nombre a déjà été converti :

JSON.parse(text, function (key, value) {
  // Ici `value` est déjà un double. Les chiffres d’origine sont perdus.
  return value;
});

Il existe une proposition TC39, « JSON.parse source text access », qui ajoute exactement cela : le reviver reçoit un objet de contexte portant le texte source de la valeur, ce qui permet d’en construire un BigInt. Ce n’est pas encore disponible partout, donc aujourd’hui les options sont :

  • Analyser avec une bibliothèque qui tokenise le texte elle-même, ce que fait l’analyseur de ce site.
  • Prétraiter le texte avec une expression régulière pour mettre les grands entiers entre guillemets avant l’analyse. Fragile : une regex ne peut pas distinguer un nombre à l’intérieur d’une chaîne d’un nombre qui est une valeur.
  • Le corriger à la source.

Les quatre vrais correctifs, par ordre de préférence

Envoyez les grands identifiants sous forme de chaînes. {"id": "12345678901234567890"}. C’est le correctif. Il coûte deux octets par valeur et il est correct dans tous les langages sans aucune configuration. Twitter l’a fait en 2010 en ajoutant un champ id_str à côté d’id, et toutes les grandes plateformes depuis en ont fait autant. Si vous concevez une API, faites-le dès le départ : un identifiant n’est pas une quantité, vous ne faites jamais d’arithmétique dessus, et lui donner un type numérique n’apporte rien.

Utilisez des unités mineures pour l’argent. Stockez 1005 centimes plutôt que 10,05. Les entiers en dessous de 2^53 sont exacts partout, et vous avez supprimé le problème des décimaux au lieu de le contourner.

Utilisez une chaîne décimale partout où la précision est l’enjeu. Prix, mesures, coordonnées qui comptent. "lat": "51.5074" est plus laid et il ne dérive pas.

Configurez votre analyseur, si vous ne pouvez pas changer le producteur. UseNumber en Go, parse_int en Python, arbitrary_precision en Rust, JSON_BIGINT_AS_STRING en PHP. Cela fonctionne, mais ne protège que les consommateurs que vous maîtrisez.

Ce que BigInt résout et ne résout pas

Le BigInt de JavaScript représente des entiers à précision arbitraire : il peut donc contenir la valeur. Il ne peut pas vous aider à analyser :

JSON.parse('{"id": 12345678901234567890}')   // la précision est déjà perdue
BigInt("12345678901234567890")               // exact, si vous avez la chaîne

Et il ne peut pas vous aider à sérialiser, car JSON.stringify lève une erreur sur un BigInt plutôt que de deviner si vous vouliez un nombre ou une chaîne :

JSON.stringify({ id: 1n })
// TypeError: Do not know how to serialize a BigInt

C’est à vous de décider, avec un replacer :

JSON.stringify({ id: 1n }, (k, v) => (typeof v === 'bigint' ? v.toString() : v));

Ce qui vous ramène à l’envoyer sous forme de chaîne, ce qui était la bonne réponse dès le départ.

Comment savoir si vous avez ce problème

Collez un vrai payload dans le validateur. Chaque entier hors de la plage sûre est signalé avec la valeur que JSON.parse vous donnerait à la place, et chaque décimal qui ne survit pas à un aller-retour en float64 est signalé séparément.

Si le compte n’est pas nul, un consommateur de ce payload lit déjà des nombres différents de ceux que vous avez envoyés, et cela dure depuis que le champ existe.

Les outils de mise en forme de ce site ne repassent jamais par JSON.parse. Ils réémettent le texte source exact de chaque nombre : mettre en forme un document contenant un identifiant de 19 chiffres redonne les mêmes 19 chiffres. C’est une exigence minimale. C’en est aussi une que la plupart des formateurs ne satisfont pas.