Cómo evitar que JSON aplane tus objetos
Corrige el aplanamiento de JSON en JavaScript con replacer, reviver, toJSON y context.source para restaurar Date, Map, Set y BigInt.
JSON.stringify convierte un Date llamando a su método toJSON, que devuelve una cadena ISO 8601, y JSON.parse no tiene un paso equivalente, así que el valor regresa como cadena a menos que lo conviertas tú mismo con un reviver.
Suele aparecer siempre igual: un objeto en caché entra en localStorage sin problemas, vuelve a salir sin problemas y, de pronto, .getFullYear() lanza una excepción o una celda de tabla muestra Invalid Date. Los datos nunca se corrompieron. Simplemente dejaron de ser un Date en algún punto entre ambas llamadas.
Este artículo cubre las dos mitades del trayecto: toJSON y el replacer en la salida, el reviver en el regreso, y el tercer argumento del reviver para los valores que pierden precisión antes incluso de que los veas. Se asume que ya conoces lo básico; si prefieres empezar por ahí, consulta cómo leer y escribir JSON en JavaScript. Este texto arranca en el segundo argumento.
Puntos clave
JSON.stringifyserializa unDatea través detoJSONcomo una cadena ISO, yJSON.parsedevuelve esa cadena sin cambios salvo que un reviver la convierta de vuelta.- Si devuelves
undefineddesde un reviver, esa clave desaparece del resultado, así que todo reviver necesita unreturn valuefinal para las claves que no maneja. - El reviver se ejecuta sobre cada par clave-valor, los hijos antes que su padre, y luego una vez más sobre el valor analizado completo bajo la clave
"". MapySetse serializan como{}, de modo que restaurarlos requiere un replacer y un reviver escritos como una pareja coordinada.- El tercer argumento del reviver es un objeto de contexto cuya propiedad
sourcecontiene el texto JSON original, lo que te permite leer un entero grande comoBigIntantes de queNumberlo redondee.
¿Cómo se ve el viaje de ida y vuelta roto de 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
La conversión de salida está bien. Es la de entrada la que no tiene ni idea de qué era esa cadena antes.
¿Qué sobrevive a la serialización JSON y qué no?
La gramática de JSON no tiene espacio para la mayor parte de lo que contiene un objeto de JavaScript, así que todo lo que queda fuera se convierte o se descarta. MDN documenta el conjunto completo de reglas de serialización de JSON.stringify; la tercera columna de abajo es lo que realmente recuperas tras un parse.
| Valor | JSON.stringify escribe | JSON.parse devuelve |
|---|---|---|
Date | cadena ISO vía toJSON | cadena |
Map, Set, WeakMap, WeakSet | {} | objeto vacío |
undefined, función, símbolo dentro de un objeto | propiedad omitida | propiedad ausente |
| Los mismos valores dentro de un array | null | null |
NaN, Infinity | null | null |
BigInt | lanza TypeError | n/a |
| Instancia de clase | objeto plano con las propiedades propias enumerables | objeto plano, prototipo perdido |
| Propiedad con clave de tipo símbolo | ignorada | ausente |
| Referencia circular | lanza TypeError | n/a |
Number, String, Boolean envueltos | primitivo desenvuelto | primitivo |
Dos filas merecen énfasis. Map y Set salen como {} porque JSON.stringify recorre las propiedades propias enumerables de un objeto, y sus entradas no viven ahí. Y dentro de un objeto, undefined, las funciones y los valores de tipo símbolo se omiten por completo, mientras que dentro de un array esos mismos valores se convierten en null, de modo que los índices sobreviven aunque los valores no.
toJSON decide qué se escribe
Cuando un valor tiene un método toJSON, JSON.stringify escribe lo que ese método devuelva e ignora el objeto en sí. El ejemplo de toJSON de MDN también muestra que al método se le pasa la clave bajo la que se encuentra su valor, así que un mismo objeto puede salir de forma distinta según dónde aparezca.
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"}}'
El campo __type es el discriminador que buscará el reviver. Escribirlo es la mitad de salida del contrato.
El reviver de JSON.parse se ejecuta en el regreso
El reviver es el segundo argumento de JSON.parse, y se invoca para cada par clave-valor producido por el análisis. El ejemplo de recorrido de MDN muestra el orden: primero van los valores más profundos, luego lo que los contiene, y una última llamada cubre el resultado completo bajo la clave "".
JSON.parse('{"a":1,"b":{"c":2,"d":{"e":3}}}', (key, value) => {
console.log(JSON.stringify(key));
return value;
});
// "a", "c", "e", "d", "b", ""
Ahora la regla que destruye datos en silencio: si devuelves undefined desde un reviver, esa clave desaparece del objeto; hazlo en la llamada raíz y el parse entero regresa como undefined.
const json = '{"user":"ada","lastLogin":"2024-03-01T09:30:00.000Z"}';
// Destructivo: toda clave no manejada se cae al final y se elimina.
JSON.parse(json, (key, value) => {
if (key === "lastLogin") return new Date(value);
});
// undefined
// Correcto: el retorno de respaldo mantiene todo lo demás intacto.
JSON.parse(json, (key, value) =>
key === "lastLogin" ? new Date(value) : value,
);
// { user: "ada", lastLogin: Date 2024-03-01T09:30:00.000Z }
Ninguna de las dos versiones lanza una excepción. Eso es lo que hace peligrosa a la primera: la pérdida aparece como un campo faltante o una cadena ISO en crudo en la salida renderizada, en lugar de como un stack trace, que es justo el tipo de defecto que un session replay saca a la luz mucho antes de que un reporte de bug lo nombre.
¿Cómo se restauran instancias de clase y Maps?
Restaurar una instancia real requiere ambas mitades del trayecto: toJSON escribe una etiqueta de tipo junto a los datos, y el reviver busca esa etiqueta y pasa los campos restantes al constructor.
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"
El mismo patrón funciona para los tipos integrados que no tienen toJSON. Un Map sale como un array de entradas mediante un replacer y regresa a través de un reviver que reconoce un 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
Esa comprobación de forma es una conjetura, y falla con los arrays vacíos: [].every(Array.isArray) es true, así que un simple [] en cualquier parte del payload regresa como un Map vacío. Una etiqueta de tipo, como la que escribe Money, elimina las conjeturas.
El replacer y el reviver son un mismo acuerdo sobre un formato de transmisión. Cambia solo uno de los dos lados y el viaje de ida y vuelta se rompe.
El replacer: filtrar en la salida
El replacer es el segundo argumento de JSON.stringify y admite dos formas. Como función, se ejecuta para cada par clave-valor, y devolver undefined omite la propiedad. Como array, actúa como lista de permitidos, donde solo cuentan las entradas de tipo cadena y número y cualquier otra cosa que pongas en la lista, símbolos incluidos, no tiene efecto alguno.
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"}'
La misma técnica descarta una clave de retrorreferencia conocida que, de lo contrario, haría que JSON.stringify lanzara un TypeError ante un ciclo. Un serializador genérico a prueba de ciclos necesita un WeakSet de objetos visitados; descartar una única clave conocida solo cubre el caso que ya tienes identificado.
Un detalle de temporización importa: toJSON se ejecuta antes de que el replacer vea un valor, así que para un Date el argumento value del replacer ya es la cadena ISO, mientras que this[key] sigue siendo el objeto original.
JSON.stringify({ lastLogin: new Date() }, function (key, value) {
// Debe ser una función regular: una arrow function no tiene enlace de `this` aquí.
return key === "lastLogin" ? this[key].getTime() : value;
});
El tercer argumento, space, solo afecta al formato. Si pides más de 10 espacios, igual obtienes 10, y una cadena de sangrado de más de 10 caracteres se recorta a sus primeros 10.
Leer el texto original con context.source
El tercer argumento del reviver es un objeto de contexto, construido de nuevo en cada llamada, cuya propiedad source contiene el texto JSON original del valor. Ese argumento aparece únicamente para los primitivos; un objeto o un array no recibe nada. Esta es la propuesta de TC39 de acceso al texto fuente en JSON.parse, que alcanzó la Etapa 4 y se incorporó en ECMAScript 2026, aprobado por Ecma International el 30 de junio de 2026.
Resuelve una pérdida que ocurre antes de que cualquier reviver pudiera intervenir: para cuando recibes value, un entero grande ya ha sido redondeado a un double.
const wire = '{"orderId": 9007199254740993}';
JSON.parse(wire).orderId;
// 9007199254740992 <- la precisión ya se perdió
JSON.parse(wire, (key, value, context) =>
key === "orderId" ? BigInt(context.source) : value,
).orderId;
// 9007199254740993n
value es el producto con pérdida. context.source es lo que realmente viajó por el cable. Comprueba la disponibilidad en tus entornos de ejecución objetivo antes de depender de ello.
Conclusión
La serialización es un contrato que escribes dos veces: una en toJSON o en un replacer, y otra en un reviver que entienda lo que produjo la primera mitad. Revisa los objetos que envías a localStorage o a una capa de caché, identifica los que llevan Date, Map, Set o instancias de clase, y dale a cada uno una etiqueta de tipo y una rama correspondiente en el reviver. Después, verifica que todos los revivers que ya tienes terminen con un return value de respaldo.
Preguntas frecuentes
¿structuredClone elimina la necesidad de un reviver?
No, porque structuredClone produce una copia en memoria en lugar de una cadena JSON, así que no puede escribirse en localStorage ni en el cuerpo de una petición. Sí preserva Date, Map, Set y las referencias circulares, pero lanza un DataCloneError con las funciones y no copia la cadena de prototipos, de modo que una instancia de clase sigue llegando como un objeto plano sin sus métodos. Restaurar instancias a partir de texto sigue necesitando un reviver.
¿Debería usar toJSON o una función replacer?
Usa toJSON cuando el tipo sea dueño de su formato de transmisión: el método vive en la clase, así que cada serialización de ese valor emite la misma forma sin que quien la invoca tenga que hacer nada. Usa un replacer cuando la regla pertenezca a un único punto de llamada, como eliminar un campo password o convertir un Map de una biblioteca que no controlas. toJSON se ejecuta primero, así que el replacer recibe lo que haya devuelto toJSON.
¿El reviver también se ejecuta sobre los elementos de un array?
Sí. Los índices del array se pasan al reviver como cadenas, así que el primer elemento llega con la clave '0', y luego el propio array se pasa hacia arriba bajo su propia clave. Devolver undefined para un elemento elimina ese elemento en lugar de desplazar el resto, dejando un hueco mientras la longitud del array permanece sin cambios. Los revivers de arrays necesitan el mismo return value de respaldo que los de objetos.
¿Cómo tipo un reviver de JSON.parse en TypeScript?
JSON.parse devuelve any en TypeScript sin importar lo que haga el reviver. La biblioteca estándar declara el reviver con una clave de tipo string, un valor any y un tipo de retorno any, así que un reviver que reconstruye Date o instancias de clase no le aporta al compilador ninguna información adicional. Anota el resultado con un tipo explícito en el punto de llamada, o pasa el valor analizado por un validador de esquema antes de confiar en su forma.
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