Perché i tuoi ID JSON cambiano valore
I numeri JSON non hanno limiti. I double IEEE 754 sì. Tutto il resto discende da qui.
Ogni affermazione di questa pagina è misurata oppure ha una fonte. Quando non è né l’una né l’altra, la pagina lo dice.
Incolla questo nella console di un browser:
JSON.parse('{"id": 12345678901234567890}')
// { id: 12345678901234567000 }
Le ultime tre cifre sono cambiate. Non è stato sollevato nulla, non ha avvisato nulla, e se quel valore era uno snowflake di Twitter o una chiave primaria di database, ora hai un record diverso. È di gran lunga il modo più comune in cui JSON danneggia i dati in silenzio, e accade nel punto in cui meno ce lo si aspetta: il parser di cui ci si fida.
Dov’è davvero il confine
La RFC 8259 non pone alcun limite alla dimensione o alla precisione di un numero JSON. La grammatica ammette un numero qualsiasi di cifre. Quindi 12345678901234567890123456789 è un numero JSON perfettamente valido, e lo è anche un decimale con duecento cifre dopo il punto.
JavaScript, ai fini di JSON, ha un unico tipo numerico: il float a doppia precisione IEEE 754. Un double ha 53 bit di mantissa, il che significa che può rappresentare esattamente ogni intero fino a 2^53-1 e non può rappresentarli tutti al di sopra. Quel valore è 9007199254740991, e JavaScript lo espone come Number.MAX_SAFE_INTEGER.
Sopra quella soglia i double si diradano. La distanza tra interi rappresentabili è 2 fino a 2^54, poi 4, poi 8, raddoppiando ogni volta. Quindi:
9007199254740992 === 9007199254740993 // true
Questi due sono lo stesso double. Per il dispari non esiste una configurazione di bit, quindi viene arrotondato al vicino pari. Il tuo ID non è stato corrotto da un bug: è finito in un buco della retta numerica.
La RFC lo prevede. La sezione 6 dice che un numero è interoperabile se «fa andata e ritorno» attraverso IEEE 754 binary64, e osserva che le implementazioni che lo usano «in generale… saranno interoperabili nel senso che le implementazioni concorderanno esattamente sui loro valori numerici». La parola che regge tutto il peso, lì, è in generale.
Non riguarda solo gli interi enormi
I decimali perdono precisione molto prima e in modo molto meno visibile:
0.1 + 0.2 // 0.30000000000000004
JSON.parse('{"v": 1.005}') // { v: 1.005 }, ma 1.005 * 100 fa 100.49999999999999
Il classico bug del denaro. Un prezzo salvato come 1.005 non può essere rappresentato esattamente come double, quindi arrotondarlo a due decimali dà 1,00 anziché 1,01. È per questo che i sistemi finanziari archiviano il denaro in unità minori come interi, o come stringhe decimali, e mai come float JSON.
Ce n’è un altro più silenzioso. Un documento JSON che contiene 1.0 diventa il numero 1 in JavaScript, e riserializzarlo produce 1. Il documento è cambiato. Per quasi tutti gli usi va benissimo; per un documento che stai hashando, firmando o confrontando, no.
Che cosa fa ogni linguaggio
I comportamenti divergono più di quanto quasi tutti si aspettino, e sapere da che parte stai decide quale sia la tua soluzione.
| Linguaggio | Comportamento predefinito con un intero grande | Puoi conservare le cifre? |
|---|---|---|
| JavaScript | Arrotonda al double più vicino, in silenzio | No. JSON.parse non ha alcun aggancio che veda il testo originale |
| Python | int a precisione arbitraria, esatto |
Sì, in automatico. parse_int e parse_float ricevono il testo grezzo |
| Go | float64 di default |
Sì. Decoder.UseNumber() conserva il testo come json.Number |
| Java (Jackson) | Integer, Long o BigInteger secondo necessità |
Sì, e USE_BIG_INTEGER_FOR_INTS lo impone |
| Rust (serde_json) | u64 / i64 / f64 |
Sì, con la funzionalità arbitrary_precision |
| PHP | int fino a PHP_INT_MAX, poi float |
In parte. JSON_BIGINT_AS_STRING li tiene come stringhe |
| C# | long, decimal o double a seconda del parser |
Sì, System.Text.Json espone il testo grezzo |
L’asimmetria è la parte pericolosa. Un servizio Python scrive un intero esatto di 19 cifre, un client JavaScript ne legge un altro, e i due sistemi sono in disaccordo su un valore che nessuno dei due ha mai registrato.
Il problema specifico di JavaScript
JavaScript è il caso a parte perché JSON.parse non ti dà alcun modo di intervenire. La funzione reviver viene eseguita dopo che il numero è già stato convertito:
JSON.parse(text, function (key, value) {
// Qui `value` è già un double. Le cifre originali sono perse.
return value;
});
Esiste una proposta TC39, «JSON.parse source text access», che aggiunge esattamente questo: il reviver riceve un oggetto di contesto che porta il testo sorgente del valore, così puoi costruirci un BigInt. Non è ancora disponibile ovunque, quindi oggi le opzioni sono:
- Analizzare con una libreria che tokenizza il testo da sé, che è quello che fa il parser di questo sito.
- Pre-elaborare il testo con un’espressione regolare per virgolettare gli interi grandi prima dell’analisi. Fragile: una regex non sa distinguere un numero dentro una stringa da un numero che è un valore.
- Risolverlo alla fonte.
Le quattro soluzioni vere, in ordine di preferenza
Manda gli ID grandi come stringhe. {"id": "12345678901234567890"}. Questa è la soluzione. Costa due byte per valore ed è corretta in ogni linguaggio senza alcuna configurazione. Twitter lo fece nel 2010 aggiungendo un campo id_str accanto a id, e da allora ogni grande piattaforma ha fatto lo stesso. Se stai progettando un’API, fallo fin dall’inizio: un identificatore non è una quantità, non ci fai mai aritmetica sopra, e dargli un tipo numerico non ti compra nulla.
Usa le unità minori per il denaro. Archivia 1005 centesimi anziché 10,05. Gli interi sotto 2^53 sono esatti ovunque, e così hai eliminato il problema decimale invece di aggirarlo.
Usa una stringa decimale ovunque la precisione sia il punto. Prezzi, misure, coordinate che contano. "lat": "51.5074" è più brutto e non deriva.
Configura il tuo parser, se non puoi cambiare chi produce. UseNumber in Go, parse_int in Python, arbitrary_precision in Rust, JSON_BIGINT_AS_STRING in PHP. Funziona, ma protegge solo i consumatori che controlli.
Che cosa risolve BigInt e che cosa no
Il BigInt di JavaScript rappresenta interi a precisione arbitraria, quindi può contenere il valore. Non può aiutarti ad analizzare:
JSON.parse('{"id": 12345678901234567890}') // la precisione è già andata
BigInt("12345678901234567890") // esatto, se hai la stringa
E non può aiutarti a serializzare, perché JSON.stringify solleva un errore su un BigInt invece di indovinare se volevi un numero o una stringa:
JSON.stringify({ id: 1n })
// TypeError: Do not know how to serialize a BigInt
Devi decidere tu, con un replacer:
JSON.stringify({ id: 1n }, (k, v) => (typeof v === 'bigint' ? v.toString() : v));
Il che ti riporta a mandarlo come stringa, che era la risposta giusta fin dal principio.
Come scoprire se hai questo problema
Incolla un payload reale nel validatore. Ogni intero fuori dall’intervallo sicuro viene segnalato con il valore che JSON.parse ti darebbe al suo posto, e ogni decimale che non sopravvive a un’andata e ritorno in float64 viene segnalato a parte.
Se il conteggio non è zero, qualche consumatore di quel payload sta già leggendo numeri diversi da quelli che hai mandato, e lo fa da quando quel campo esiste.
Gli strumenti di formattazione di questo sito non passano mai per JSON.parse. Riemettono il testo sorgente esatto di ogni numero, quindi formattare un documento con un ID di 19 cifre restituisce le stesse 19 cifre. È un’asticella bassa. Ed è anche una che la maggior parte dei formattatori non supera.