Saltar al contenido
jsonbeautifiers
Español

Por qué tus IDs de JSON cambian de valor

Los números JSON no tienen cota. Los doubles IEEE 754 sí. Todo lo demás se deriva de ahí.

Cada afirmación de esta página está medida o tiene fuente. Cuando no es ninguna de las dos, lo dice.

Pega esto en la consola de un navegador:

JSON.parse('{"id": 12345678901234567890}')
// { id: 12345678901234567000 }

Los tres últimos dígitos han cambiado. No se lanzó nada, no avisó nada, y si ese valor era un snowflake de Twitter o una clave primaria de base de datos, ahora tienes un registro distinto. Esta es, con diferencia, la forma más común en que JSON daña datos en silencio, y ocurre en el sitio donde menos se lo espera la gente: el parser en el que confían.

Dónde está la frontera de verdad

La RFC 8259 no pone ningún límite al tamaño ni a la precisión de un número JSON. La gramática permite cualquier cantidad de dígitos. Así que 12345678901234567890123456789 es un número JSON perfectamente válido, y también lo es un decimal con doscientas posiciones detrás del punto.

JavaScript tiene un único tipo numérico a efectos de JSON: el float de doble precisión IEEE 754. Un double tiene 53 bits de mantisa, lo que significa que puede representar exactamente todos los enteros hasta 2^53-1 y no puede representar todos los que están por encima. Ese valor es 9007199254740991, y JavaScript lo expone como Number.MAX_SAFE_INTEGER.

Por encima de ahí, los doubles se vuelven dispersos. El hueco entre enteros representables es de 2 hasta 2^54, luego 4, luego 8, duplicándose cada vez. Así que:

9007199254740992 === 9007199254740993   // true

Esos dos son el mismo double. No existe un patrón de bits para el impar, así que se redondea a su vecino par. Tu ID no se corrompió por un bug; aterrizó en un agujero de la recta numérica.

La RFC lo anticipa. La sección 6 dice que un número es interoperable si «da la vuelta» (round-trip) a través de IEEE 754 binary64, y señala que las implementaciones que lo usan «en general… serán interoperables en el sentido de que las implementaciones coincidirán exactamente en sus valores numéricos». La palabra que hace todo el trabajo ahí es en general.

No va solo de enteros enormes

Los decimales pierden precisión mucho antes y de forma mucho menos visible:

0.1 + 0.2                    // 0.30000000000000004
JSON.parse('{"v": 1.005}')   // { v: 1.005 }, pero 1.005 * 100 da 100.49999999999999

El bug monetario clásico. Un precio guardado como 1.005 no se puede representar de forma exacta como double, así que redondearlo a dos decimales da 1,00 en vez de 1,01. Por eso los sistemas financieros guardan el dinero en unidades menores como enteros, o como cadenas decimales, y nunca como floats de JSON.

Hay otro más silencioso. Un documento JSON que contiene 1.0 se convierte en el número 1 en JavaScript, y al serializarlo de vuelta produce 1. El documento ha cambiado. Para la mayoría de los usos eso da igual; para un documento que estás hasheando, firmando o comparando, no.

Qué hace cada lenguaje

Los comportamientos divergen más de lo que la mayoría espera, y saber en qué lado estás decide cuál es tu solución.

Lenguaje Por defecto, para un entero grande ¿Puedes conservar los dígitos?
JavaScript Redondea al double más cercano, en silencio No. JSON.parse no tiene ningún hook que vea el texto original
Python int de precisión arbitraria, exacto Sí, automáticamente. parse_int y parse_float reciben el texto crudo
Go float64 por defecto Sí. Decoder.UseNumber() conserva el texto como json.Number
Java (Jackson) Integer, Long o BigInteger según haga falta Sí, y USE_BIG_INTEGER_FOR_INTS lo fuerza
Rust (serde_json) u64 / i64 / f64 Sí, con la característica arbitrary_precision
PHP int hasta PHP_INT_MAX, después float En parte. JSON_BIGINT_AS_STRING los conserva como cadenas
C# long, decimal o double según el parser Sí, System.Text.Json expone el texto crudo

La asimetría es la parte peligrosa. Un servicio en Python escribe un entero exacto de 19 dígitos, un cliente JavaScript lee otra cosa, y los dos sistemas discrepan sobre un valor que ninguno de ellos llegó a registrar.

El problema específico de JavaScript

JavaScript es la excepción porque JSON.parse no te da ninguna forma de intervenir. La función reviver se ejecuta después de que el número ya se ha convertido:

JSON.parse(text, function (key, value) {
  // Aquí `value` ya es un double. Los dígitos originales se han perdido.
  return value;
});

Existe una propuesta de TC39, «JSON.parse source text access», que añade exactamente eso: el reviver recibe un objeto de contexto que lleva el texto fuente del valor, así que puedes construir un BigInt a partir de él. Todavía no está disponible en todas partes, así que hoy las opciones son:

  • Parsear con una biblioteca que tokenice el texto por su cuenta, que es lo que hace el parser de este sitio.
  • Preprocesar el texto con una expresión regular para entrecomillar los enteros grandes antes de parsear. Frágil: una regex no puede distinguir un número dentro de una cadena de un número que es un valor.
  • Arreglarlo en el origen.

Las cuatro soluciones reales, por orden de preferencia

Envía los IDs grandes como cadenas. {"id": "12345678901234567890"}. Esta es la solución. Cuesta dos bytes por valor y es correcta en todos los lenguajes sin ninguna configuración. Twitter lo hizo en 2010 añadiendo un campo id_str junto a id, y todas las grandes plataformas desde entonces han hecho lo mismo. Si estás diseñando una API, hazlo desde el principio: un identificador no es una cantidad, nunca haces aritmética con él, y darle un tipo numérico no te aporta nada.

Usa unidades menores para el dinero. Guarda 1005 céntimos en vez de 10,05. Los enteros por debajo de 2^53 son exactos en todas partes, y así has eliminado el problema decimal en vez de rodearlo.

Usa una cadena decimal para todo aquello en lo que la precisión sea el punto. Precios, medidas, coordenadas que importan. "lat": "51.5074" es más feo y no se desvía.

Configura tu parser, si no puedes cambiar al productor. UseNumber en Go, parse_int en Python, arbitrary_precision en Rust, JSON_BIGINT_AS_STRING en PHP. Funciona, pero solo protege a los consumidores que controlas.

Qué resuelve BigInt y qué no

El BigInt de JavaScript representa enteros de precisión arbitraria, así que puede contener el valor. Lo que no puede es ayudarte a parsear:

JSON.parse('{"id": 12345678901234567890}')   // la precisión ya se perdió
BigInt("12345678901234567890")               // exacto, si tienes la cadena

Y tampoco puede ayudarte a serializar, porque JSON.stringify lanza un error ante un BigInt en vez de adivinar si querías un número o una cadena:

JSON.stringify({ id: 1n })
// TypeError: Do not know how to serialize a BigInt

Tienes que decidirlo tú, con un replacer:

JSON.stringify({ id: 1n }, (k, v) => (typeof v === 'bigint' ? v.toString() : v));

Lo cual te devuelve a enviarlo como cadena, que era la respuesta correcta desde el principio.

Cómo averiguar si tienes este problema

Pega un payload real en el validador. Todos los enteros fuera del rango seguro se señalan con el valor que JSON.parse te daría en su lugar, y todos los decimales que no sobreviven a un ida y vuelta por float64 se señalan aparte.

Si el recuento no es cero, algún consumidor de ese payload ya está leyendo números distintos de los que enviaste, y lleva haciéndolo desde que ese campo existe.

Las herramientas de formateo de este sitio nunca pasan por JSON.parse. Vuelven a emitir el texto fuente exacto de cada número, así que formatear un documento con un ID de 19 dígitos devuelve los mismos 19 dígitos. Es un listón bajo. Y es uno que la mayoría de formateadores no supera.