JSON é mais simples do que julga, e é esse o problema
Publicado a 08/08/2025 · 20 min de leitura · Ferramentas para programadores
Daniel Okonkwo — Programador front-end e redator de Tecnologia na OneKitly
Desempenho web · Formatos de ficheiro
Verificado a partir de 7 fontes
A gramática do JSON cabe numa página, e é precisamente por isso que tem fugas. A ECMA-404 e a RFC 8259 definem seis espécies de valor — objeto, array, string, número, true ou false, null — e mais nada. Não há tipo inteiro: o JSON tem um único tipo numérico, e um analisador de JavaScript pousa-o num double IEEE 754, pelo que 1234567890123456789 volta como o double 1234567890123456768, um erro de 21, enquanto o analisador do Python o devolve exato. Os mesmos bytes, dois valores diferentes. Não há tipo data: um carimbo temporal é uma string cujo formato é uma convenção. NaN e Infinity não se escrevem — o JSON.stringify converte-os em null e o JSON.parse rejeita os literais, embora o módulo json do Python os emita por omissão e produza documentos que não são JSON. O zero negativo sobrevive de forma assimétrica: analisar -0 dá -0, mas serializar -0 dá 0. As chaves duplicadas são legais na gramática, a RFC 8259 apenas diz que o resultado é imprevisível, e todos os analisadores correntes guardam silenciosamente a última. Os comentários não constam da gramática. Um JSON Schema gerado fixa a forma, nunca o significado, e o deduzido de uma única amostra sobreajusta muito. E a ordem das chaves é um facto ao nível dos bytes: troque duas e a carga útil passa a ter outro hash, o que parte as assinaturas.
O JSON não tem tipo inteiro, nem tipo data, nem comentários, nem esquema. Cada uma dessas ausências produz um erro concreto: um identificador de 19 dígitos volta desviado em 21, um carimbo temporal torna-se uma string que ninguém acordou, NaN não se escreve e as chaves duplicadas são legais. Tudo executado, em duas linguagens.
Seis espécies de valor, e nenhuma é a que queria
O JSON está especificado duas vezes, pela ECMA-404 e pela RFC 8259, e ambos os documentos são curtos porque há muito pouco a dizer. Um valor é um objeto, um array, uma string, um número, o literal true, o literal false ou o literal null. É todo o sistema de tipos. Tudo o resto que julga que o JSON tem foi acrescentado pela sua linguagem à entrada ou à saída.
Conte as ausências. Sem tipo inteiro, apenas uma produção numérica única que o analisador tem de projetar sobre o tipo numérico de que dispõe. Sem data, sem hora, sem duração. Sem binário — obtém base64 dentro de uma string, o que custa mais um terço de bytes. Sem comentários. Sem enumerações, sem intervalos, sem campos obrigatórios, sem esquema nenhum. Sem garantia quanto à ordem das chaves. Sem maneira de exprimir uma referência a outra parte do mesmo documento: um grafo tem de ser achatado à mão.
Nada disso é um defeito de conceção. O JSON foi extraído de uma sintaxe literal de JavaScript para mover dados entre dois programas que já concordavam sobre o que os dados significavam, e faz esse trabalho com um tamanho e uma velocidade que nada superou. O defeito aparece quando um formato que não transporta significado é usado como se transportasse. Tudo o que se segue é uma instância desse erro.
O número que volta errado
A gramática numérica do JSON aceita qualquer decimal que consiga escrever. A RFC 8259 avisa que as implementações variam e recomenda ficar dentro da dupla precisão IEEE 754, o que na prática significa inteiros até 2^53 − 1 = 9 007 199 254 740 991. Acima disso, os doubles deixam de conseguir representar inteiros consecutivos, e um analisador de JavaScript não tem outro sítio onde pôr o valor.
Execute-o. JSON.parse('9007199254740993') devolve 9007199254740992 — a entrada era ímpar, a saída é par, e nenhum erro foi levantado. Pegue num identificador realista de 19 dígitos do género dos que as plataformas sociais e os serviços de mensagens distribuem: JSON.parse('{"id": 1234567890123456789}') guarda o double 1234567890123456768, que o JavaScript depois imprime como 1234567890123456800 por ser o decimal mais curto que regressa ao mesmo double. O identificador que recebeu difere do que guardou em 21, e do que imprime em 32. Um id mais curto de 18 dígitos, 175928847299117063, volta como 175928847299117060.
Agora entregue os bytes idênticos ao Python 3.13: json.loads devolve exatamente 1234567890123456789, como int do Python, porque o analisador do Python projeta a produção numérica JSON sobre um inteiro de precisão arbitrária quando não há parte fracionária. Um documento, duas linguagens, dois valores diferentes, e ambos os analisadores são conformes. É todo o problema numa linha: o JSON não diz ao analisador que tipo numérico usar, portanto o analisador decide, e a decisão não é a mesma em toda a parte.
O mesmo buraco engole a distinção inteiro/decimal no outro sentido. JSON.stringify(1.0) produz os dois caracteres "1", pelo que um campo que a sua base declarou decimal chega como algo que um gerador de esquemas rotulará de inteiro. Não há maneira de escrever 1.0 em JSON e vê-lo continuar 1.0 depois de uma ida e volta por JavaScript. A correção para identificadores é brusca e universal: envie-os como strings. As plataformas mordidas cedo entregam ambos os campos — um id numérico e um id textual — precisamente por não conseguirem corrigir os seus clientes.
Datas, NaN, Infinity e um sinal de menos que desaparece
JSON.stringify(new Date(...)) produz "2026-08-28T14:30:00.000Z", o que parece indicar que o JSON percebe de datas. Não percebe. O ECMAScript define Date.prototype.toJSON, e o JSON.stringify chama-o; o resultado é uma string vulgar. Volte a analisá-la e typeof dá "string". O formato calha ser o que a RFC 3339 perfila a partir do ISO 8601, mas nada no JSON o exige, e assim que um segundo serviço serializa com outra biblioteca obtém uma data com outra forma — um inteiro epoch, uma hora local sem desvio, um invólucro "/Date(1234567890)/" vindo de uma pilha mais antiga. Todas são JSON igualmente válido e igualmente impossível de interpretar sem acordo prévio.
NaN e os dois infinitos não estão na gramática, logo não podem ser escritos de todo. JSON.stringify({a: NaN, b: Infinity, c: -Infinity}) devolve {"a":null,"b":null,"c":null} — três valores de vírgula flutuante distintos colapsados num único null, em silêncio. JSON.parse('{"a":NaN}') lança um SyntaxError. Dentro de um array, undefined, as funções e os símbolos também se tornam null; como valores de objeto são eliminados por completo, pelo que {a: undefined, b: 1} se serializa como {"b":1} e uma chave simplesmente desaparece.
O Python faz algo pior e mais interessante: json.dumps({'a': nan, 'b': inf}) emite por omissão {"a": NaN, "b": Infinity}, e json.loads relê-o sem problema. Essa saída não é JSON. Atravessará os seus próprios serviços Python intacta e falhará assim que chegar a um analisador conforme noutra linguagem qualquer, que costuma ser o navegador, que costuma ser produção. A opção existe — allow_nan=False lança uma exceção — e quase ninguém a liga.
O zero negativo é o mais pequeno e o mais estranho do conjunto. O IEEE 754 tem dois zeros, e contam onde um sinal transporta informação — uma taxa de variação, um sentido de arredondamento, um saldo que chegou exatamente a nada por baixo. JSON.parse('-0') devolve -0: Object.is(JSON.parse('-0'), -0) é true, e 1 dividido por ele dá −Infinity. Mas JSON.stringify(-0) devolve o único carácter 0. O valor sobrevive portanto num sentido e não no outro, e uma ida e volta completa faz 1 ÷ x saltar de −Infinity para +Infinity sem aviso nenhum.
As chaves duplicadas são legais, e cada analisador escolhe uma em silêncio
A gramática JSON define um objeto como uma sequência de pares nome-valor separados por vírgulas. Não diz que os nomes têm de diferir. A RFC 8259 trata o assunto em prosa e não na gramática: os nomes DEVERIAM ser únicos, e avisa que as implementações perante um duplicado se comportam de forma diferente — umas ficam com a última, outras com a primeira, outras assinalam um erro. Esse DEVERIAM é a palavra mais fraca que a RFC podia usar, e significa que qualquer analisador que encontre aceita o documento.
Na prática o grosso convergiu. JSON.parse('{"role":"admin","role":"user"}') devolve {"role":"user"} no Node, e o json.loads do Python devolve o mesmo. Ganha a última, sem aviso, sem forma de detetar a posteriori que o documento teve duas. A informação de que existiu um duplicado é destruída pela própria análise, e é isso que dificulta a depuração: quando o seu código vê o objeto, a prova já desapareceu.
A consequência a levar a sério é que um documento pode significar uma coisa para o componente que o verifica e outra para o que age sobre ele, se esses dois componentes usarem analisadores que divergem — ou se um inspecionar o texto cru e o outro o objeto analisado. A forma geral desse perigo e a regra que dela decorre estão tratadas no artigo deste sítio sobre verificação de tokens assinados: valide e aja sobre a mesma representação analisada, nunca sobre duas. A mitigação concreta aqui é mais simples. Rejeite os documentos com nomes duplicados à entrada, antes de mais nada olhar para eles; um analisador em fluxo ou uma passagem de tokenização prévia vê o duplicado que o JSON.parse deita fora.
O que um esquema gerado traz, e onde sobreajusta
O JSON Schema tapa o maior buraco: é um vocabulário para dizer que chaves têm de existir, que tipo tem cada valor, que valores são permitidos e até onde vai o aninhamento. Um validador transforma um documento informe num sim ou num não na fronteira do seu sistema, o que vale muito. Gerar um primeiro rascunho a partir de uma amostra que já tem é o caminho mais rápido, e é o que faz o gerador de esquemas deste sítio.
A armadilha é que um esquema deduzido de um só documento descreve esse documento, não a família a que pertence. Pegue numa amostra de aspeto inofensivo: um objeto com um id inteiro de 42, um nome, um array de uma só etiqueta textual, um score inteiro de 10, um manager que calha ser null e um booleano. Um gerador ingénuo produz tipo inteiro para id e score, tipo null para manager, array de strings para tags, todas as chaves em required e additionalProperties a false.
Valide agora contra ele cinco documentos posteriores perfeitamente legítimos. Um score que chega como 10.5 é rejeitado, porque a amostra era inteira. Um manager finalmente preenchido com um objeto é rejeitado, porque a amostra era null. Um documento que omite um campo opcional é rejeitado, porque o gerador pôs todas as chaves em required. Um documento com um novo campo email é rejeitado, porque additionalProperties era false. Um array tags que contém um número é rejeitado. Cinco em cinco, e cada um é um registo real que o seu sistema devia ter aceitado.
A outra metade da lição é o que o mesmo esquema aceita de bom grado: um registo com nome vazio e um score de −999 passa todas as verificações, porque o JSON Schema valida forma e nunca significado. Nada no vocabulário sabe que um nome deveria ser não vazio ou que um score tem um piso. Use então a geração como primeiro rascunho e depois edite-a à mão: alargue integer para number onde um decimal seja possível, substitua um tipo null por uma união anulável, corte required para os campos genuinamente obrigatórios, deixe additionalProperties aberto a menos que esteja a trancar o contrato de propósito, e acrescente as restrições minLength, minimum e enum que transportam as suas verdadeiras regras de negócio.
A ordem das chaves e a assinatura que deixa de bater certo
Os objetos JSON são não ordenados enquanto modelo de dados, mas um documento JSON é uma sequência de bytes e os bytes têm ordem. O JSON.stringify emite as chaves textuais por ordem de inserção — com uma exceção que apanha muita gente. O ECMAScript coloca primeiro as chaves de índice inteiro, por ordem crescente, à frente de toda a chave textual. Construa um objeto atribuindo z, depois user_2, depois "2", depois user_1, depois "1": o stringify devolve {"1":5,"2":3,"z":1,"user_2":2,"user_1":4}. As duas chaves de aspeto numérico saltaram para a frente e ordenaram-se numericamente; o resto ficou na ordem em que escreveu. A análise faz o mesmo: um documento recebido numa ordem sai do JSON.parse noutra.
Isso torna-se um incidente de produção assim que faz o hash de uma carga útil. Dois serviços descrevem a mesma transferência de 100 €: um escreve {"amount":100,"currency":"EUR","to":"acct_9"} e o outro os mesmos três campos a começar por "to". Os objetos são profundamente iguais. Os resumos SHA-256 são 1648f3b9016a5b95… e bc654befe505d093…, e um HMAC calculado sobre cada um difere desde o primeiro byte. O recetor rejeita um pedido que é, semanticamente, exatamente o que esperava.
Ordenar as chaves antes de serializar corrige este caso concreto — ambos os objetos canonizam para a forma que começa por amount e os resumos coincidem. Mas ordenar por si só não é uma forma canónica, porque o mesmo valor ainda pode escrever-se de mais do que uma maneira: "é" e "\u00e9" são a mesma string e bytes diferentes, 1e21 e 1000000000000000000000 são o mesmo número, e um serializador pode ou não escapar a barra. A RFC 8785, JSON Canonicalization Scheme, é a resposta normalizada: fixa a ordem das chaves por unidade de código UTF-16, prende a formatação dos números às regras do ECMAScript e define exatamente que caracteres são escapados. Se puder evitar o problema, evite: assine e verifique os bytes exatos que recebeu, e nunca reserialize um documento que está prestes a verificar.
A lista prática
Envie cada identificador como string, seja qual for o seu tipo na sua base de dados. Acordem por escrito um único formato de carimbo temporal — a RFC 3339 com desvio explícito é o menos discutido — e rejeite tudo o resto na fronteira em vez de adivinhar. Decida de antemão o que significa um valor em falta, e escolha ou null ou a ausência, não ambas. Nunca deixe um NaN ou um infinito chegar a um serializador: converta-o em null, em string ou em erro, deliberadamente, onde o cálculo acontece.
Rejeite os nomes duplicados à entrada. Gere um esquema para poupar teclas e depois edite-o antes de confiar nele. Canonize, ou assine os bytes crus, nunca um objeto reserializado. E guarde os ficheiros de configuração, onde os humanos precisam de comentários e vírgulas finais, num formato que os tenha — que é o tema do artigo seguinte.
| No documento | O Node devolve | O Python devolve | Consequência |
|---|---|---|---|
| 9007199254740993 | 9007199254740992 | 9007199254740993 (exato) | Um número ímpar torna-se par, sem erro |
| 1234567890123456789 | 1234567890123456768, impresso como 1234567890123456800 | 1234567890123456789 (exato) | Identificador desviado em 21; os dois serviços divergem |
| 1.0 | 1, e o JSON.stringify reescreve-o como "1" | 1.0 como float, reescrito como 1.0 | Distinção decimal/inteiro perdida só numa linguagem |
| Uma Date serializada, "2026-08-28T14:30:00.000Z" | Uma string (typeof é "string") | Uma string | Não há tipo data; o formato é uma convenção |
| NaN escrito como literal | SyntaxError — rejeitado | nan — aceite, e emitido por omissão | O Python escreve documentos que não são JSON |
| -0 serializado a partir de um programa | Escrito 0; o sinal desapareceu | Escrito -0.0; o sinal sobrevive | 1 ÷ x salta de −Infinity para +Infinity |
| {"role":"admin","role":"user"} | role = user (ganha o último) | role = user (ganha o último) | Gramática legal, imprevisível segundo a RFC 8259 |
Perguntas frequentes
- Como faço passar um identificador de 64 bits por JSON sem perder dígitos?
- Envie-o como string. É a única correção que funciona em toda a parte, e é por isso que as plataformas mordidas primeiro publicam dois campos — um id numérico e uma versão textual do mesmo id — em vez de partirem os seus clientes. Um reviver do JSON.parse não o vai ajudar: o reviver corre depois de o tokenizador já ter produzido o double, pelo que quando o seu callback vê o valor os dígitos já desapareceram. Existem analisadores compatíveis com bigint e funcionam, porque leem o texto do token e decidem eles próprios o tipo, mas mudam o que o seu código recebe e cada comparação, JSON.stringify e operação aritmética a jusante tem de ser auditada. Se não puder mudar o produtor, detete pelo menos o estrago: um inteiro cujo valor absoluto exceda Number.MAX_SAFE_INTEGER, 9007199254740991, deixou de ser fiável, e uma ida e volta por String(BigInt(x)) comparada com o token cru dir-lhe-á se sobreviveu. E quando passar para strings, lembre-se de que um identificador textual ordena lexicograficamente: "10" vem antes de "9", logo qualquer ordenação de que dependia tem de mudar para um campo numérico separado ou para a base de dados.
- As chaves duplicadas são mesmo JSON válido?
- Sim, gramaticalmente. Nem a ECMA-404 nem a gramática da RFC 8259 proíbem um nome repetido, pelo que um documento que o contenha analisa-se. A RFC 8259 acrescenta um requisito em prosa ao nível DEVERIA — os nomes deveriam ser únicos — e avisa que as implementações divergem quando não o são, listando três comportamentos plausíveis: ficar com o último, ficar com o primeiro, ou assinalar um erro. Medido aqui, o JSON.parse do Node e o json.loads do Python ficam ambos com o último, pelo que {"role":"admin","role":"user"} dá o papel user em ambos. Como a própria análise destrói a prova, não consegue detetar o duplicado a partir do objeto resultante, e nenhuma validação posterior o encontrará. A regra prática é rejeitar à entrada: ou um analisador em fluxo ou baseado em eventos que assinale cada nome à medida que aparece, ou uma passagem de tokenização barata que conte nomes por objeto, e recusa o pedido. Um esquema não o fará por si — o JSON Schema opera sobre a instância analisada, altura em que o duplicado já foi resolvido.
- O JSON não tem comentários. O que uso para os ficheiros de configuração?
- Os comentários foram deixados de fora de propósito, com o raciocínio de que as pessoas começariam a pôr diretivas de análise neles. As vírgulas finais, as strings com plicas e as chaves sem aspas faltam pela mesma razão: a gramática é pequena para que todas as implementações concordem. É uma boa propriedade para dados em trânsito e péssima para um ficheiro que um humano mantém. A divisão honesta é usar JSON estrito para tudo o que uma máquina produz ou transmite, e algo mais acolhedor para tudo o que uma pessoa edita. JSONC — JSON com comentários — é o que vários editores e cadeias de ferramentas aceitam, e é o passo mais pequeno. O JSON5 acrescenta vírgulas finais, chaves sem aspas, plicas, números hexadecimais e os literais NaN e Infinity que faltam ao JSON. O TOML foi concebido especificamente para configuração e tem datas a sério. O YAML é o mais implantado e é o tema do artigo seguinte desta série, incluindo as maneiras como a sua inferência de tipos o vai surpreender. A única coisa a não fazer é uma chave "_comment": é legal, sobrevive às idas e voltas, e sobrevive também até ao que serializar a seguir, onde ninguém a espera.
- Um esquema gerado substitui a validação escrita à mão?
- Não, por duas razões distintas. Primeiro, a geração sobreajusta: medido acima, um esquema deduzido de um único registo rejeitou cinco em cinco registos posteriores legítimos — um decimal onde a amostra tinha um inteiro, um campo preenchido onde a amostra tinha null, um opcional ausente, um campo acrescentado e um array de tipos mistos. Cada um é uma evolução normal de uma carga útil real. Segundo, o JSON Schema valida forma e não significado por conceção, pelo que o mesmo esquema aceitou sem reclamar um registo com nome vazio e um score de −999. Aquilo para que a geração serve mesmo é a parte fastidiosa: enumerar cinquenta chaves e os seus tipos sem uma gralha, e dar-lhe um ficheiro de partida que já se analisa. Trate a saída como rascunho e faça quatro correções antes de confiar nela — alargue integer para number onde possa surgir um decimal, faça dos campos anuláveis uma união em vez do tipo null, pode required ao que é genuinamente obrigatório, e decida conscientemente se additionalProperties deve ser false. Acrescente depois as restrições que transportam as regras de negócio, porque são exatamente as que nenhum gerador consegue deduzir dos dados.
- Porque é que dois serviços calculam hashes diferentes da mesma carga útil?
- Porque um hash é sobre bytes e os dois serviços produziram bytes diferentes para o mesmo valor. Três coisas variam de forma independente. A ordem das chaves é o culpado habitual: dois objetos profundamente iguais serializam de forma diferente se as suas chaves foram inseridas noutra ordem, e o ECMAScript ainda por cima iça para a frente as chaves com aspeto de índice inteiro por ordem numérica crescente, pelo que "2" e "10" passam à frente de todas as outras independentemente de onde as escreveu. O escape das strings é o segundo: "é" escrito diretamente e escrito \u00e9 são a mesma string e bytes diferentes, e os serializadores divergem quanto a escapar a barra e os dois separadores de linha U+2028 e U+2029. A formatação dos números é o terceiro: 1e21 e a sua forma decimal longa denotam o mesmo double, e 1.0 serializa como 1. A correção certa depende de onde está. Se está a verificar algo que recebeu, faça o hash dos bytes exatos que chegaram e nunca os reserialize, o que contorna os três problemas de uma vez. Se tem de fazer o hash de um valor que construiu, use uma forma canónica definida: a RFC 8785 especifica uma que fixa em conjunto a ordem das chaves, a formatação dos números e o escape, e há bibliotecas que a implementam na maioria das linguagens.
- O JSON.parse é seguro com entrada não fiável?
- Estruturalmente, sim, e bem mais seguro do que o eval que substituiu: a gramática não contém nenhuma construção executável, pelo que um documento analisado não pode executar código. Surgem duas preocupações concretas e ambas são menos alarmantes do que a sua reputação. A poluição de protótipo não é causada pelo JSON.parse — a especificação exige que crie propriedades de dados, pelo que JSON.parse('{"__proto__": {"admin": true}}') dá um objeto com uma propriedade própria vulgar chamada __proto__ e deixa o Object.prototype intacto; verificado aqui, ({}).admin continua undefined. A poluição acontece depois, numa fusão recursiva ingénua ou num ciclo de atribuição desprotegido que percorre essas chaves: é aí que a guarda pertence. O estouro de pilha por aninhamento profundo é também em grande medida histórico: o analisador do V8 é iterativo, e um milhão de níveis de arrays aninhados analisaram sem erro no Node 26. O que merece mesmo ser limitado é o tamanho e o tempo. Um analisador tem de ler o documento inteiro antes de produzir seja o que for, logo um corpo sem limite significa memória sem limite, e as chaves duplicadas, os números desmesurados e os campos inesperados continuam a precisar de rejeição na fronteira. Limite o corpo do pedido e depois valide.
Artigos que podem interessar-lhe
Todos os guias →Ferramentas relacionadas
Fontes
- Ecma International — ECMA-404: The JSON Data Interchange Syntax
- IETF — RFC 8259 — The JavaScript Object Notation (JSON) Data Interchange Format
- JSON Schema — JSON Schema specification — core and validation vocabularies
- Ecma International / TC39 — ECMA-262 — JSON.parse, JSON.stringify, Date.prototype.toJSON and OrdinaryOwnPropertyKeys
- IETF — RFC 8785 — JSON Canonicalization Scheme (JCS)
- IETF — RFC 3339 — Date and Time on the Internet: Timestamps
- Python Software Foundation — json — JSON encoder and decoder (allow_nan, parse_int, and the non-standard NaN/Infinity extension)
Detetaste um erro neste artigo?