Seis tipos, precisión numérica y trampas de codificación
En resumen: JSON solo tiene seis tipos, pero cada uno de ellos puede transformar tus datos en silencio. Este artículo cubre en una sola pasada las trampas de los números, las cadenas y null.

En resumen: comprueba primero si los campos de ID o de importes superan 2^53 - 1 o necesitan muchos decimales de precisión, y después si los límites de longitud de las cadenas cuentan puntos de código o unidades UTF-16; cualquier valor fuera del rango de enteros seguros llévalo como cadena en vez de confiar en que el analizador preserve la precisión.
JSON tiene menos tipos de los que mucha gente cree, y más trampas: los números se desplazan, las cadenas se rompen y null no es lo mismo que una clave ausente.
1. El panorama de tipos
| Tipo | Ejemplo de literal | Restricción en JSON | Asignación habitual |
|---|---|---|---|
| Object | {"a": 1} |
Las claves deben ser cadenas, los valores pueden ser cualquier cosa | Object / Map / struct |
| Array | [1, "a", null] |
Los elementos pueden ser heterogéneos | Array / List / slice |
| String | "text" |
UTF-8, comillas dobles | String |
| Number | 1, -1.5, 1e9 |
Sin ceros iniciales, sin +, sin NaN |
double / Decimal |
| Boolean | true / false |
En minúsculas | bool |
| null | null |
El único valor nulo | null / Option::None |
Fíjate en las dos cosas que no existen: no hay tipo fecha ni hay tipo entero. Todas las fechas son cadenas, y todos los enteros y valores de coma flotante comparten el único tipo "number".
2. Números: la doble precisión es la respuesta por defecto
2.1 El límite de los enteros seguros
El mayor entero que la doble precisión de IEEE 754 puede representar exactamente es 2^53 - 1 = 9007199254740991. Más allá de eso, aparecen huecos entre enteros adyacentes:
JSON.parse('{"id": 9007199254740993}').id
// 9007199254740992
JSON.stringify({ id: 9007199254740993 })
// '{"id":9007199254740992}'
Esto no es un problema de JSON; es un problema del paso "texto → doble precisión". Cualquier tubería con JavaScript en medio (gateway, BFF, frontend, servicio en Node) lo encontrará.
Soluciones (ordenadas según cuánto las recomiendo):
- Usa cadenas para los ID en toda la tubería:
{"id": "9007199254740993"}, la que menos problemas da de todas; - Analiza con tipos de alta precisión en el servidor:
BigInteger/BigDecimalde Java,json.Numberde Go,decimal.Decimalde Python; - En el frontend, usa el reviver de
JSON.parseo un analizador que admita números grandes (comojson-bigint) para convertir el campo enBigInt, pero ten en cuenta queBigIntno puede pasarse directamente aJSON.stringify.
2.2 Los decimales no son valores exactos
0.1 + 0.2 // 0.30000000000000004
JSON.stringify(0.1 + 0.2)
// "0.30000000000000004"
Representa siempre los importes monetarios como enteros en la unidad mínima de la moneda (céntimos) o como cadenas; nunca los acumules con coma flotante.
2.3 Trampas numéricas durante la serialización
JSON.stringify({ a: 1e21 }) // {"a":1e+21}
JSON.stringify({ a: -0 }) // {"a":0}
JSON.stringify({ a: Infinity }) // {"a":null}
JSON.stringify({ a: NaN }) // {"a":null}
JSON.stringify({ a: 10n }) // TypeError: Do not know how to serialize a BigInt
Infinity y NaN se convierten en silencio en null, que es la clase de "los datos simplemente desaparecieron" más difícil de rastrear.
2.4 Comportamiento por defecto según el lenguaje
| Lenguaje | Tipo numérico por defecto | Casos que requieren tratamiento explícito |
|---|---|---|
| JavaScript | double | Enteros grandes, serialización de BigInt |
| Go | float64 (encoding/json) |
Usa json.Number o Decoder.UseNumber() para preservar la precisión |
| Java (Jackson) | int/long/double/BigDecimal inferido del literal |
Forzar DeserializationFeature.USE_BIG_DECIMAL_FOR_FLOATS |
| Python | int (precisión arbitraria) / float |
Usa parse_float=Decimal para preservar la precisión decimal |
| Rust (serde_json) | i64/u64/f64 |
La precisión arbitraria requiere habilitar arbitrary_precision |
3. Cadenas: un flujo de bytes UTF-8 visto a través de UTF-16
3.1 La longitud no es lo mismo que el número de caracteres
En JavaScript, "🎉".length es 2, porque los emoji viven en los planos suplementarios y se representan mediante un par sustituto.
Si el servidor limita la longitud por "número de caracteres" mientras el frontend valida con length, es seguro que ambos lados no coincidirán.
'🎉'.length // 2
Array.from('🎉').length // 1
[...'👨👩👧'].length // 5 (incluidos los joiners de ancho cero)
Cuando necesites un conteo riguroso, usa Intl.Segmenter (por clúster de grafemas) o cuenta puntos de código Unicode en el backend, y deja clara la definición en la documentación de la API.
3.2 Sustitutos huérfanos
Un sustituto alto huérfano como "\ud800" es JSON gramaticalmente válido (\uXXXX acepta cualquier secuencia de cuatro dígitos hexadecimales), pero no es texto Unicode válido.
Desde ES2019, JSON.stringify escapa los sustitutos huérfanos como \ud800, garantizando que la salida esté "bien formada".
Al transferir entre lenguajes, valores como este serán rechazados si el otro lado valida Unicode de forma estricta.
3.3 \u2028 y \u2029
Los separadores de línea y de párrafo son válidos en JSON, pero insertarlos directamente en código fuente JavaScript o en una etiqueta <script> provoca un error de sintaxis.
Cuando incrustes JSON en HTML, escápalos siempre o pasa a analizar desde una cadena con JSON.parse.
3.4 Tres fuentes de texto chino corrupto
- El archivo en sí está en GBK y se lee como UTF-8;
- La cabecera de la respuesta declara
charset=gbk, pero el contenido es en realidad UTF-8; - Pasa por un middleware que "decodifica a cadena y vuelve a codificar", así que el texto chino se transcodifica una segunda vez.
Orden de investigación: mira primero los bytes (xxd), después la cabecera de la respuesta y, por último, el middleware.
4. null frente a "esta clave no existe"
En JSON estos dos son distintos:
{"nickname": null}
{}
- El primero significa "el campo existe, con un valor vacío";
- El segundo significa "el campo no existe".
En TypeScript esto se convierte en la diferencia entre string | null y nickname?: string; en Java se convierte en la diferencia entre "null explícito" y "valor por defecto".
Sin una convención acordada, aparecen estas divisiones:
// El enfoque del frontend A
if (data.nickname === undefined) { /* tratarlo como que no tiene apodo */ }
// El enfoque del frontend B
if (!data.nickname) { /* tratar null y la cadena vacía juntos */ }
Recomendación: la documentación de la API debe indicar "usa null de forma uniforme cuando falte un valor, nunca omitas la clave", o lo contrario, pero elige solo una opción;
cuando actualices una API, usa la semántica de PATCH para evitar la ambigüedad de "¿no se envió o se envió como null".
5. Arrays y objetos
5.1 Los arrays no son "homogéneos"
[1, "two", {"three": 3}, null]
Gramaticalmente válido, pero la definición de tipo se vuelve muy difícil de escribir. En las API, intenta mantener homogéneos los elementos de un array; para los casos heterogéneos, pasa a un array de objetos con un campo discriminante type.
5.2 Las claves de un objeto no tienen orden, pero mucha gente depende del orden
La especificación dice que un objeto es "una colección no ordenada de pares nombre/valor". En la práctica, V8 conserva el orden de inserción (salvo las claves enteras, que se ordenan). Construir lógica sobre el orden de las claves (tomar "la primera clave", por ejemplo) significa depender de un detalle de implementación.
5.3 Los arrays dispersos y los huecos no existen
El [1, , 3] de JavaScript se serializa como [1,null,3]: el hueco se convierte en null. JSON no tiene el concepto de "ranura".
6. Una lista de comprobación accionable
- [ ] Todos los ID, importes monetarios y enteros de gran tamaño son explícitamente cadenas en el contrato de la API;
- [ ] La documentación de la API expone la semántica de
nullfrente a un campo omitido; - [ ] Los límites de longitud de las cadenas indican si cuentan puntos de código o unidades UTF-16;
- [ ] Filtra
Infinity/NaN/undefinedantes de serializar, o conviértelos explícitamente ennull; - [ ] El JSON incrustado en HTML está escapado;
- [ ] Frontend y backend tienen cada uno un caso de prueba de contrato que cubre "entero grande + emoji + null".
Preguntas frecuentes
¿Cómo debo tratar los enteros superiores a 2^53 - 1?
La opción que menos problemas da es enviar campos como los ID como cadenas en toda la tubería, por ejemplo {"id": "9007199254740993"}. En el servidor puedes preservar la precisión con BigInteger / BigDecimal de Java, json.Number de Go o decimal.Decimal de Python. En el frontend, un analizador como json-bigint puede convertir el campo en BigInt, pero recuerda que BigInt no puede pasarse directamente a JSON.stringify.
¿Por qué Infinity y NaN se convierten en null tras serializar?
El tipo number de JSON no acepta literales no estándar como NaN e Infinity, así que JSON.stringify los convierte en null en silencio, sin ningún error. Esta es la clase de "los datos simplemente desaparecieron" más difícil de rastrear. Filtra estos valores explícitamente antes de serializar, o conviértelos en null según tu convención de negocio y regístralo.
¿Debo enviar null u omitir el campo del todo?
Cualquiera de las dos funciona, pero la documentación de la API debe elegir una y dejarla clara, de lo contrario cada frontend asumirá algo distinto. En los ejemplos, {"nickname": null} y {} se corresponden con string | null y nickname?: string en TypeScript. Usa la semántica de PATCH al actualizar una API para evitar la ambigüedad de "¿no se envió o se envió como null".