Ir para o conteúdo
jsonbeautifiers
Português

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.