Por que os seus IDs em JSON mudam de valor
Números JSON são ilimitados. Doubles IEEE 754 não são. Todo o resto decorre disso.
Cada afirmação desta página foi medida ou tem fonte. Quando não é nem uma coisa nem outra, a página diz isso.
Cole isto no console de um navegador:
JSON.parse('{"id": 12345678901234567890}')
// { id: 12345678901234567000 }
Os três últimos dígitos mudaram. Nada foi lançado, nada avisou, e se aquele valor era um snowflake do Twitter ou uma chave primária de banco de dados, agora você tem outro registro. Esta é, de longe, a forma mais comum de o JSON estragar dados em silêncio, e acontece no lugar em que as pessoas menos esperam: no parser em que elas confiam.
Onde fica a fronteira de verdade
A RFC 8259 não impõe limite algum ao tamanho nem à precisão de um número JSON. A gramática permite qualquer quantidade de dígitos. Então 12345678901234567890123456789 é um número JSON perfeitamente válido, e um decimal com duzentas casas depois do ponto também.
O JavaScript tem um único tipo numérico para efeitos de JSON: o float de dupla precisão IEEE 754. Um double tem 53 bits de significando, o que significa que ele consegue representar exatamente todo inteiro até 2^53-1 e não consegue representar todos acima disso. Esse valor é 9007199254740991, e o JavaScript o expõe como Number.MAX_SAFE_INTEGER.
Acima daí, os doubles ficam esparsos. O intervalo entre inteiros representáveis é 2 até 2^54, depois 4, depois 8, dobrando a cada vez. Então:
9007199254740992 === 9007199254740993 // true
Esses dois são o mesmo double. Não existe um padrão de bits para o ímpar, então ele arredonda para o vizinho par. O seu ID não foi corrompido por um bug; ele caiu num buraco da reta numérica.
A RFC antecipa isso. A seção 6 diz que um número é interoperável se ele “faz a ida e volta” pelo IEEE 754 binary64, e observa que implementações que o usam “em geral… serão interoperáveis no sentido de que as implementações concordarão exatamente sobre os seus valores numéricos”. A palavra que faz todo o trabalho ali é em geral.
Não é só sobre inteiros enormes
Decimais perdem precisão muito antes e de forma muito menos visível:
0.1 + 0.2 // 0.30000000000000004
JSON.parse('{"v": 1.005}') // { v: 1.005 }, mas 1.005 * 100 dá 100.49999999999999
O bug monetário clássico. Um preço guardado como 1.005 não pode ser representado exatamente como double, então arredondá-lo para duas casas dá 1,00 em vez de 1,01. É por isso que sistemas financeiros guardam dinheiro em unidades menores como inteiros, ou como strings decimais, e nunca como floats de JSON.
Existe outro mais silencioso. Um documento JSON contendo 1.0 vira o número 1 no JavaScript, e serializá-lo de volta produz 1. O documento mudou. Para a maioria dos usos isso não faz diferença; para um documento que você está hasheando, assinando ou comparando, faz.
O que cada linguagem faz
Os comportamentos divergem mais do que quase todo mundo espera, e saber de que lado você está decide qual é a sua correção.
| Linguagem | Padrão para um inteiro grande | Dá para manter os dígitos? |
|---|---|---|
| JavaScript | Arredonda para o double mais próximo, em silêncio | Não. O JSON.parse não tem gancho nenhum que veja o texto original |
| Python | int de precisão arbitrária, exato |
Sim, automaticamente. parse_int e parse_float recebem o texto cru |
| Go | float64 por padrão |
Sim. Decoder.UseNumber() mantém o texto como json.Number |
| Java (Jackson) | Integer, Long ou BigInteger conforme a necessidade |
Sim, e USE_BIG_INTEGER_FOR_INTS força |
| Rust (serde_json) | u64 / i64 / f64 |
Sim, com o recurso arbitrary_precision |
| PHP | int até PHP_INT_MAX, depois float |
Em parte. JSON_BIGINT_AS_STRING mantém como strings |
| C# | long, decimal ou double dependendo do parser |
Sim, o System.Text.Json expõe o texto cru |
A assimetria é a parte perigosa. Um serviço em Python escreve um inteiro exato de 19 dígitos, um cliente JavaScript lê outra coisa, e os dois sistemas discordam sobre um valor que nenhum deles chegou a registrar.
O problema específico do JavaScript
O JavaScript é a exceção porque o JSON.parse não te dá jeito nenhum de intervir. A função reviver roda depois de o número já ter sido convertido:
JSON.parse(text, function (key, value) {
// Aqui `value` já é um double. Os dígitos originais se foram.
return value;
});
Existe uma proposta do TC39, “JSON.parse source text access”, que acrescenta exatamente isso: o reviver recebe um objeto de contexto carregando o texto de origem do valor, então dá para construir um BigInt a partir dele. Ainda não está disponível em todo lugar, então hoje as opções são:
- Fazer o parsing com uma biblioteca que tokeniza o texto por conta própria, que é o que o parser deste site faz.
- Pré-processar o texto com uma expressão regular para colocar aspas nos inteiros grandes antes do parsing. Frágil: uma regex não distingue um número dentro de uma string de um número que é um valor.
- Consertar na origem.
As quatro correções de verdade, em ordem de preferência
Envie IDs grandes como strings. {"id": "12345678901234567890"}. Esta é a correção. Custa dois bytes por valor e está certa em toda linguagem, sem configuração nenhuma. O Twitter fez isso em 2010 acrescentando um campo id_str ao lado de id, e toda plataforma grande desde então fez o mesmo. Se você está desenhando uma API, faça desde o começo: um identificador não é uma quantidade, você nunca faz aritmética com ele, e dar a ele um tipo numérico não compra nada.
Use unidades menores para dinheiro. Guarde 1005 centavos em vez de 10,05. Inteiros abaixo de 2^53 são exatos em toda parte, e assim você eliminou o problema decimal em vez de contorná-lo.
Use uma string decimal em tudo em que a precisão é o ponto. Preços, medidas, coordenadas que importam. "lat": "51.5074" é mais feio e não desvia.
Configure o seu parser, se você não pode mudar quem produz. UseNumber no Go, parse_int no Python, arbitrary_precision no Rust, JSON_BIGINT_AS_STRING no PHP. Funciona, mas só protege os consumidores que você controla.
O que o BigInt resolve e o que não
O BigInt do JavaScript representa inteiros de precisão arbitrária, então ele consegue segurar o valor. O que ele não consegue é te ajudar a fazer o parsing:
JSON.parse('{"id": 12345678901234567890}') // a precisão já foi
BigInt("12345678901234567890") // exato, se você tem a string
E também não ajuda a serializar, porque o JSON.stringify lança erro em um BigInt em vez de adivinhar se você queria um número ou uma string:
JSON.stringify({ id: 1n })
// TypeError: Do not know how to serialize a BigInt
Você tem que decidir, com um replacer:
JSON.stringify({ id: 1n }, (k, v) => (typeof v === 'bigint' ? v.toString() : v));
O que te devolve para mandar como string, que era a resposta certa desde o começo.
Como descobrir se você tem esse problema
Cole um payload real no validador. Todo inteiro fora da faixa segura é sinalizado com o valor que o JSON.parse te daria no lugar, e todo decimal que não sobrevive a uma ida e volta em float64 é sinalizado à parte.
Se a contagem não for zero, algum consumidor daquele payload já está lendo números diferentes dos que você mandou, e faz isso desde que o campo existe.
As ferramentas de formatação deste site nunca passam pelo JSON.parse. Elas reemitem o texto de origem exato de cada número, então formatar um documento com um ID de 19 dígitos devolve os mesmos 19 dígitos. É uma barra baixa. E é uma que a maioria dos formatadores não alcança.