12k
All articles

Como Impedir que o JSON Achate Seus Objetos

Corrija o achatamento de JSON em JavaScript com replacer, reviver, toJSON e context.source para restaurar Date, Map, Set e BigInt.

OpenReplay Team
OpenReplay Team
Como Impedir que o JSON Achate Seus Objetos

JSON.stringify converte um Date chamando seu método toJSON, que retorna uma string ISO 8601, e JSON.parse não possui uma etapa correspondente, portanto o valor volta como string a menos que você mesmo o converta com um reviver.

Isso geralmente aparece da mesma forma: um objeto em cache entra no localStorage sem problemas, sai sem problemas, e então .getFullYear() lança um erro ou uma célula de tabela renderiza Invalid Date. Os dados nunca foram corrompidos. Eles apenas deixaram de ser um Date em algum ponto entre as duas chamadas.

Este artigo cobre as duas metades do percurso: toJSON e o replacer na saída, o reviver no retorno, e o terceiro argumento do reviver para valores que perdem precisão antes mesmo de você vê-los. Ele pressupõe que você já conhece o básico; se quiser começar por aí, veja how to read and write JSON in JavaScript. Este texto começa no segundo argumento.

Principais Conclusões

  • JSON.stringify serializa um Date através de toJSON como uma string ISO, e JSON.parse retorna essa string inalterada a menos que um reviver a converta de volta.
  • Retornar undefined de um reviver faz com que aquela chave desapareça do resultado, portanto todo reviver precisa de um return value final para as chaves que ele não trata.
  • O reviver é executado em cada par chave-valor, filhos antes de seus pais, e então mais uma vez sobre todo o valor analisado sob a chave "".
  • Map e Set são serializados como {}, então restaurá-los exige um replacer e um reviver escritos como um par correspondente.
  • O terceiro argumento do reviver é um objeto de contexto cuja propriedade source contém o texto JSON original, o que permite ler um inteiro grande como um BigInt antes que Number o arredonde.

Como É o Round Trip Quebrado do JSON?

const session = { user: "ada", lastLogin: new Date("2024-03-01T09:30:00Z") };

const wire = JSON.stringify(session);
// '{"user":"ada","lastLogin":"2024-03-01T09:30:00.000Z"}'

const back = JSON.parse(wire);
typeof back.lastLogin; // "string"
back.lastLogin.getFullYear(); // TypeError

A conversão de saída está correta. É a de entrada que não faz ideia do que aquela string costumava ser.

O Que Sobrevive à Serialização JSON, e O Que Não Sobrevive?

A gramática do JSON não tem espaço para a maior parte do que um objeto JavaScript carrega, portanto qualquer coisa fora dela é convertida ou descartada. A MDN documenta o conjunto completo de regras de serialização para JSON.stringify; a terceira coluna abaixo mostra o que você realmente recebe de volta após um parse.

ValorJSON.stringify escreveJSON.parse retorna
Datestring ISO via toJSONstring
Map, Set, WeakMap, WeakSet{}objeto vazio
undefined, função, symbol em um objetopropriedade omitidapropriedade ausente
Os mesmos valores dentro de um arraynullnull
NaN, Infinitynullnull
BigIntlança TypeErrorn/a
Instância de classeobjeto simples com as propriedades próprias enumeráveisobjeto simples, prototype perdido
Propriedade com chave symbolignoradaausente
Referência circularlança TypeErrorn/a
Number, String, Boolean encapsuladosprimitivo desencapsuladoprimitivo

Duas linhas merecem destaque. Map e Set saem como {} porque JSON.stringify percorre as propriedades próprias enumeráveis de um objeto, e suas entradas não estão ali. E dentro de um objeto, undefined, funções e valores symbol são omitidos por completo, enquanto dentro de um array esses mesmos valores viram null, de modo que os índices sobrevivem mesmo que os valores não.

toJSON Decide O Que É Escrito

Quando um valor possui um método toJSON, JSON.stringify escreve o que quer que esse método devolva e ignora o objeto em si. O exemplo de toJSON da MDN também mostra o método recebendo a chave sob a qual seu valor está, de modo que um mesmo objeto pode sair de formas diferentes dependendo de onde aparece.

class Money {
  constructor(amount, currency) {
    this.amount = amount;
    this.currency = currency;
  }
  format() {
    return `${(this.amount / 100).toFixed(2)} ${this.currency}`;
  }
  toJSON() {
    return { __type: "Money", amount: this.amount, currency: this.currency };
  }
}

JSON.stringify({ total: new Money(4599, "EUR") });
// '{"total":{"__type":"Money","amount":4599,"currency":"EUR"}}'

O campo __type é o discriminador que o reviver vai procurar. Escrevê-lo é a metade de saída do contrato.

O Reviver do JSON.parse É Executado no Retorno

O reviver é o segundo argumento de JSON.parse, e é chamado para cada par chave-valor produzido pela análise. O exemplo de travessia da MDN mostra a ordem: os valores mais profundos vêm primeiro, depois o que os contém, e uma última chamada cobre todo o resultado sob a chave "".

JSON.parse('{"a":1,"b":{"c":2,"d":{"e":3}}}', (key, value) => {
  console.log(JSON.stringify(key));
  return value;
});
// "a", "c", "e", "d", "b", ""

Agora a regra que silenciosamente destrói dados: retorne undefined de um reviver e aquela chave desaparece do objeto; faça isso na chamada raiz e todo o parse volta como undefined.

const json = '{"user":"ada","lastLogin":"2024-03-01T09:30:00.000Z"}';

// Destructive: every unhandled key falls off the end and is deleted.
JSON.parse(json, (key, value) => {
  if (key === "lastLogin") return new Date(value);
});
// undefined

// Correct: the fallback return keeps everything else intact.
JSON.parse(json, (key, value) =>
  key === "lastLogin" ? new Date(value) : value,
);
// { user: "ada", lastLogin: Date 2024-03-01T09:30:00.000Z }

Nenhuma das duas versões lança erro. É isso que torna a primeira perigosa: a perda se manifesta como um campo ausente ou uma string ISO crua na saída renderizada em vez de um stack trace, que é o tipo de defeito que um session replay revela muito antes de um relatório de bug identificá-lo.

Como Restaurar Instâncias de Classe e Maps?

Restaurar uma instância real exige as duas metades do percurso: toJSON escreve uma marcação de tipo junto com os dados, e o reviver verifica essa marcação e passa os campos restantes ao construtor.

const reviver = (key, value) =>
  value && value.__type === "Money"
    ? new Money(value.amount, value.currency)
    : value;

JSON.parse('{"total":{"__type":"Money","amount":4599,"currency":"EUR"}}', reviver)
  .total.format(); // "45.99 EUR"

O mesmo padrão funciona para tipos nativos que não possuem toJSON. Um Map sai como um array de entradas via um replacer e volta através de um reviver que reconhece um array de arrays.

const flags = new Map([["beta", true], ["darkMode", false]]);

const text = JSON.stringify({ flags }, (key, value) =>
  value instanceof Map ? Array.from(value.entries()) : value,
);
// '{"flags":[["beta",true],["darkMode",false]]}'

const restored = JSON.parse(text, (key, value) =>
  Array.isArray(value) && value.every(Array.isArray) ? new Map(value) : value,
);
restored.flags.get("beta"); // true

Esse teste de formato é um palpite, e ele falha com arrays vazios: [].every(Array.isArray) é true, então um [] simples em qualquer lugar do payload volta como um Map vazio. Uma marcação de tipo, como a que Money escreve, elimina a adivinhação.

Replacer e reviver são um único acordo sobre um formato de transmissão. Altere apenas um dos lados e o round trip quebra.

O Replacer: Filtrando na Saída

O replacer é o segundo argumento de JSON.stringify e assume duas formas. Como função, ele é executado para cada par chave-valor e retornar undefined omite a propriedade. Como array, ele funciona como uma lista de permissão, na qual apenas entradas de string e número contam e qualquer outra coisa que você coloque na lista, symbols inclusive, não tem efeito algum.

const account = { id: 7, email: "ada@example.com", password: "hunter2" };

JSON.stringify(account, (key, value) => (key === "password" ? undefined : value));
// '{"id":7,"email":"ada@example.com"}'

JSON.stringify(account, ["id", "email"]);
// '{"id":7,"email":"ada@example.com"}'

A mesma técnica descarta uma chave de referência retroativa conhecida que de outra forma faria JSON.stringify lançar um TypeError em um ciclo. Um serializador genérico à prova de ciclos precisa de um WeakSet de objetos visitados; descartar uma única chave nomeada só resolve o caso que você já conhece.

Um detalhe de ordem importa: toJSON é executado antes de o replacer ver um valor, portanto, para um Date, o argumento value do replacer já é a string ISO enquanto this[key] ainda é o objeto original.

JSON.stringify({ lastLogin: new Date() }, function (key, value) {
  // Must be a regular function: an arrow function has no `this` binding here.
  return key === "lastLogin" ? this[key].getTime() : value;
});

O terceiro argumento, space, afeta apenas a formatação. Peça mais de 10 espaços e você ainda recebe 10, e uma string de indentação com mais de 10 caracteres é reduzida aos seus 10 primeiros.

Lendo o Texto Original com context.source

O terceiro argumento do reviver é um objeto de contexto, construído do zero a cada chamada, cuja propriedade source contém o texto JSON original do valor. Esse argumento aparece apenas para primitivos; um objeto ou um array não recebe nada. Esta é a proposta do TC39 de acesso ao texto-fonte no JSON.parse, que alcançou o Stage 4 e foi lançada no ECMAScript 2026, aprovado pela Ecma International em 30 de junho de 2026.

Ela resolve uma perda que ocorre antes que qualquer reviver pudesse intervir: quando você recebe value, um inteiro grande já foi arredondado para um double.

const wire = '{"orderId": 9007199254740993}';

JSON.parse(wire).orderId;
// 9007199254740992  <- precision already gone

JSON.parse(wire, (key, value, context) =>
  key === "orderId" ? BigInt(context.source) : value,
).orderId;
// 9007199254740993n

value é o produto com perda. context.source é o que realmente estava na transmissão. Verifique a disponibilidade nos runtimes de destino antes de depender disso.

Conclusão

Serialização é um contrato que você escreve duas vezes: uma em toJSON ou em um replacer, outra em um reviver que entende o que a primeira metade produziu. Reveja os objetos que você envia para o localStorage ou para uma camada de cache, encontre aqueles que carregam Date, Map, Set ou instâncias de classe, e dê a cada um uma marcação de tipo e um ramo correspondente no reviver. Depois, verifique se todo reviver que você já tem termina com um return value de fallback.

Perguntas Frequentes

O structuredClone elimina a necessidade de um reviver?

Não, porque o structuredClone produz uma cópia em memória em vez de uma string JSON, portanto ela não pode ser gravada no localStorage nem em um corpo de requisição. Ele de fato preserva Date, Map, Set e referências circulares, mas lança um DataCloneError em funções e não copia a cadeia de prototypes, então uma instância de classe ainda chega como um objeto simples sem seus métodos. Restaurar instâncias a partir de texto continua exigindo um reviver.

Devo usar toJSON ou uma função replacer?

Use toJSON quando o tipo é dono do seu formato de transmissão: o método vive na classe, então toda serialização daquele valor emite o mesmo formato sem que o chamador precise fazer nada. Use um replacer quando a regra pertence a um único ponto de chamada, como remover um campo de senha ou converter um Map de uma biblioteca que você não controla. O toJSON é executado primeiro, então o replacer recebe o que quer que o toJSON tenha retornado.

O reviver também é executado em elementos de array?

Sim. Os índices de array são passados ao reviver como strings, então o primeiro elemento chega com a chave '0', e o array em si é então passado adiante sob sua própria chave. Retornar undefined para um elemento apaga aquele elemento em vez de deslocar os demais, deixando um buraco enquanto o length do array permanece inalterado. Revivers para arrays precisam do mesmo return value de fallback que os revivers de objetos precisam.

Como tipar um reviver de JSON.parse em TypeScript?

JSON.parse retorna any no TypeScript, independentemente do que o reviver faça. A biblioteca padrão declara o reviver com uma chave string, um valor any e um tipo de retorno any, então um reviver que reconstrói Date ou instâncias de classe não fornece nenhuma informação adicional ao compilador. Anote o resultado com um tipo explícito no ponto de chamada, ou passe o valor analisado por um validador de schema antes de confiar em seu formato.

Open-source session replay

Complete picture for complete understanding

Capture every clue your frontend is leaving so you can instantly get to the root cause of any issue with OpenReplay — the open-source session replay tool for developers. Self-host it in minutes, and have complete control over your customer data.

Star on GitHub12k

We use cookies to improve your experience. By using our site, you accept cookies.