JSON.parse y la frontera sin tipos
Lección 6 de 43 · El lenguaje: de JavaScript a TypeScript estricto
Las cinco lecciones anteriores trataron datos que ya estaban dentro del programa. Esta trata del momento en que entran. JSON es el formato por el que llega casi todo lo externo: el cuerpo de una petición HTTP, el mensaje de una cola, la respuesta de un servicio de terceros, un archivo de configuración. Y JSON.parse es la puerta por la que pasa. Esa puerta tiene una particularidad incómoda: el compilador no puede comprobar nada de lo que la cruza, y por defecto tampoco avisa de que no puede. Nota sobre la terminología: en este artículo, serializar significa convertir un valor a texto JSON, y parsear significa el proceso inverso. Frontera del sistema es cualquier punto donde entran datos que el programa no generó.

De JavaScript a TypeScript estrictoLección 6/7
Temario del curso
De JavaScript a TypeScript estricto
Lección 6 de 7
CursoDe JavaScript a TypeScript estricto · Lección 6 de 7
JSON.parse devuelve any
Definición — any: Tipo que significa "trata este valor como si TypeScript no existiera". Sobre un any está permitido cualquier acceso, cualquier llamada y cualquier asignación, sin comprobación alguna.
La firma de JSON.parse en la librería estándar declara que devuelve any. Y es honesto: nadie puede saber en tiempo de compilación qué forma tendrá un texto que llegará en ejecución.
El problema no es esa declaración, sino lo que any hace a continuación:
ts
const datos = JSON.parse(mensaje); // any
datos.pedido.cliente.email; // compila
datos.noExiste.nadaDeNada; // compila también
datos.toUpperCase(); // y esto
const total: number = datos.nombre; // y esto, aunque sea un textoNinguna de esas líneas produce un error del compilador. Todas pueden fallar en ejecución. Es exactamente la situación que TypeScript existe para evitar, ocurriendo en el punto del sistema donde los datos son menos fiables.
El contagio
Lo que hace peligroso a any no es cada uso individual, sino que se propaga. Cualquier expresión derivada de un any es también any:
ts
function procesar(entrada: any) {
const cliente = entrada.cliente; // any
const nombre = cliente.nombre; // any
return construirRespuesta(nombre); // se pasa un any donde se espera un string
}El compilador acepta la última línea porque any es asignable a cualquier tipo. Es decir, any no solo desactiva las comprobaciones donde aparece: desactiva también la que protegía al otro lado.
Un solo JSON.parse sin tratar puede dejar sin verificar una rama entera del código, y nada en la pantalla lo indica.
La mentira cómoda de la aserción
Definición — Aserción de tipo (as): Sintaxis que le dice al compilador que trate un valor como si fuera de otro tipo. No comprueba nada en ejecución: solo cambia lo que el compilador cree.
La reacción instintiva es decirle al compilador qué forma tiene:
ts
const pedido = JSON.parse(mensaje) as Pedido;Esto se lee como una solución y no lo es. Si el texto que llegó no tiene esa forma, no hay ningún error en ese punto. El programa sigue como si todo estuviera bien y falla más tarde, donde alguien usa un campo que no existe, muy lejos de la causa.
Has cambiado un error inmediato y localizado por uno tardío y difícil de rastrear.
unknown: el primer paso honesto
Definición — unknown: Tipo que acepta cualquier valor, igual que any, pero no permite hacer nada con él hasta comprobar de qué se trata.
ts
const raw: unknown = JSON.parse(mensaje.body); // no confíes en la forma
raw.pedido; // ❌ error: `raw` es de tipo unknownEse error es exactamente el objetivo. Te obliga a comprobar antes de usar, y esa comprobación es la que convierte una suposición en un hecho:
ts
const raw: unknown = JSON.parse(texto);
if (typeof raw === 'object' && raw !== null && 'id' in raw) {
// aquí el compilador ya sabe algo más de `raw`
}La diferencia entre los dos tipos, resumida:
anyunknown¿Acepta cualquier valor?SíSí¿Permite usarlo sin comprobar?SíNo¿Es asignable a otros tipos?SíNo
Escribir esas comprobaciones a mano para un objeto con quince campos es tedioso, y por eso en un servicio real se delega en un validador de esquemas, que hace lo mismo de forma declarativa. Ese es el tema del bloque de validación del curso.
Lo que importa ahora es el criterio: el resultado de un parseo es unknown hasta que algo lo compruebe, y unknown es lo que fuerza a que ese algo exista.
Por qué la anotación funciona:
JSON.parsedevuelveany, yanyes asignable a cualquier cosa, así que anotar el resultado comounknownno da error y a la vez cierra el agujero. Es una de las pocas anotaciones que aportan más que la inferencia.
El atajo para recordarlo
Un dato que viene de fuera del proceso no tiene tipo, tiene una esperanza. Mientras nadie la haya comprobado, el tipo correcto es unknown.
Qué se pierde al serializar
Hasta aquí hemos visto el problema de tipos. Ahora, el problema de datos.
JSON tiene seis tipos de valor: objeto, array, texto, número, booleano y null. Todo lo que no encaje en esa lista se transforma o desaparece al convertir a texto, y no vuelve al parsear.
Estos son los tres casos que más muerden.
Las fechas se convierten en texto
JSON.stringify llama al método toJSON de Date, que produce una cadena en formato ISO. Al parsear, esa cadena se queda como cadena: nadie la vuelve a convertir en fecha.
ts
const original = { creadoEn: new Date() };
const vuelta = JSON.parse(JSON.stringify(original));
typeof vuelta.creadoEn; // 'string'
vuelta.creadoEn.getTime(); // ❌ error en ejecución: no es un DateLo peligroso es la combinación con la aserción de antes. Si el tipo declara creadoEn: Date y el valor real es un string, el compilador no dirá nada y el fallo aparecerá en la primera operación con fechas. Es uno de los bugs más frecuentes en la frontera entre un servicio y otro.
undefined desaparece; null sobrevive
JSON.stringify omite las propiedades cuyo valor es undefined, en vez de escribirlas:
ts
JSON.stringify({ a: 1, b: undefined }); // '{"a":1}'
JSON.stringify({ a: 1, b: null }); // '{"a":1,"b":null}'Y en arrays, donde no puede omitir una posición sin desplazar el resto, los convierte en null:
ts
JSON.stringify([1, undefined, 3]); // '[1,null,3]'La consecuencia práctica es importante: una propiedad opcional puesta a undefined y una propiedad ausente son indistinguibles después de un viaje por JSON. Si tu sistema necesita diferenciar "no se envió este campo" de "se envió vacío a propósito", undefined no sirve para expresarlo a través de la frontera. Usa null.
Las clases se vuelven objetos planos
Una instancia de clase pierde su prototipo al serializar. Lo que vuelve tiene los campos, pero no los métodos ni la identidad:
ts
class Pedido {
constructor(public id: string) {}
esValido(): boolean { return this.id.length > 0; }
}
const vuelta = JSON.parse(JSON.stringify(new Pedido('1')));
vuelta.id; // '1'
vuelta.esValido(); // ❌ error: no es una función
vuelta instanceof Pedido; // falseEsto tiene una consecuencia de diseño que reaparece más adelante: los objetos que cruzan la frontera del sistema son datos planos, no objetos de dominio con comportamiento. Reconstruir el objeto de dominio a partir de esos datos es un paso explícito, y el sitio donde se hace tiene nombre propio, el mapper, y una lección dedicada.
Resumen de lo que no sobrevive
Valor originalQué queda al volverDateCadena en formato ISOundefined en un objetoLa propiedad desapareceundefined en un arraynullInstancia de claseObjeto plano sin métodosMap, SetObjeto vacío {}BigIntLanza un error al serializarFunciónSe omite
La regla general es que solo sobrevive lo que JSON sabe representar.
replacer y reviver: intervenir en la conversión
Ambas funciones aceptan un segundo argumento que permite intervenir en cada par clave-valor mientras se recorre la estructura.
replacer: enmascarar al serializar
Definición — Replacer: Función que JSON.stringify llama por cada par clave-valor de todos los niveles. Lo que devuelve es lo que se escribe en el texto final. Si devuelve undefined, la propiedad se omite.
Su uso más importante es no filtrar datos sensibles al log:
ts
const seguro = JSON.stringify(payload, (k, v) => (k === 'password' ? '***' : v));Como se aplica a todos los niveles, una contraseña anidada tres niveles adentro también queda enmascarada.
Devolver undefined elimina la propiedad por completo:
ts
const CAMPOS_SENSIBLES = new Set(['password', 'token', 'documento', 'tarjeta']);
const sinSensibles = JSON.stringify(payload, (clave, valor) =>
CAMPOS_SENSIBLES.has(clave) ? undefined : valor,
);Este patrón es la base de un logger que no vuelca información privada por accidente, y se desarrolla en la lección de logging estructurado.
Ten en cuenta: el
replacertambién acepta un array de claves en vez de una función, y entonces actúa como lista blanca: solo se serializan esas claves. Es más restrictivo y más seguro cuando sabes exactamente qué debe salir.
reviver: reconstruir al parsear
Definición — Reviver: Función que JSON.parse llama por cada par clave-valor ya parseado. Lo que devuelve es lo que queda en el objeto final.
Sirve para deshacer parte de lo que se perdió al serializar, típicamente las fechas:
ts
const ISO = /^\d{4}-\d{2}-\d{2}T/;
const datos = JSON.parse(texto, (clave, valor) =>
typeof valor === 'string' && ISO.test(valor) ? new Date(valor) : valor,
);Sé consciente de sus dos límites:
-
Decide por la forma del texto, no por el significado del campo. Cualquier cadena que se parezca a una fecha se convertirá, incluida una que debía seguir siendo texto.
-
No valida nada. Sigue sin haber ninguna comprobación de que el objeto tenga la forma esperada. Es una conversión, no una validación.
La alternativa más fiable, y la que se usa en un servicio real, es convertir los campos en un paso explícito y por nombre, dentro del esquema de validación o del mapper, donde sabes exactamente qué campo es una fecha y qué debe pasar si no lo es.
Ejercicios
Ejercicio 1: rastrear el contagio
Explica qué comprobaciones se pierden en cada línea y hasta dónde llega el efecto.
Contexto: construirRespuesta espera un objeto con un campo nombre de tipo string.
ts
function procesar(texto: string) {
const datos = JSON.parse(texto);
const cliente = datos.cliente;
return construirRespuesta(cliente);
}
function construirRespuesta(cliente: { nombre: string }) {
return cliente.nombre.toUpperCase();
}Ejercicio 2: de any a unknown
Reescribe la función anterior para que el resultado del parseo sea unknown y el código no compile hasta comprobar que datos es un objeto con un campo cliente que a su vez tiene un nombre de tipo texto. Lanza un error explícito si la comprobación falla.
Ejercicio 3: el viaje de ida y vuelta
Escribe un objeto que contenga una fecha, un campo con valor undefined, un campo con valor null y un array con un hueco undefined. Serialízalo, parséalo y anota qué recibe cada campo al volver.
Explica por qué el campo undefined y un campo ausente son indistinguibles después del viaje.
Ejercicio 4: un log que no filtra
Escribe una función logSeguro(evento: string, payload: unknown): void que imprima el payload en una sola línea de JSON, enmascarando cualquier campo cuyo nombre esté en una lista de campos sensibles, a cualquier nivel de anidamiento.
Pista: el replacer se aplica en todos los niveles de la estructura, no solo en el primero.
Soluciones
Solución 1
LíneaQué se pierdeconst datos = JSON.parse(texto)datos es any: se apagan todas las comprobaciones sobre élconst cliente = datos.clienteEl acceso no se comprueba, y cliente hereda el anyconstruirRespuesta(cliente)Se acepta el any donde se esperaba { nombre: string }
El error de ejecución aparece en la última función, en cliente.nombre.toUpperCase(), que es el único sitio donde el código realmente toca el dato. Si cliente no existía, o su nombre no era un texto, el fallo se ve ahí, tres funciones más allá de donde estuvo la causa.
Solución 2
ts
function procesar(texto: string) {
const datos: unknown = JSON.parse(texto);
if (
typeof datos !== 'object' || datos === null ||
!('cliente' in datos)
) {
throw new Error('Formato inesperado: falta cliente');
}
const cliente = datos.cliente;
if (
typeof cliente !== 'object' || cliente === null ||
!('nombre' in cliente) || typeof cliente.nombre !== 'string'
) {
throw new Error('Formato inesperado: cliente.nombre no es un texto');
}
return construirRespuesta({ nombre: cliente.nombre });
}Antes de añadir las guardas, el compilador da un error en datos.cliente que dice que la propiedad no existe en el tipo unknown.
Y fíjate en la verbosidad: comprobar dos campos ya ocupa doce líneas. Con quince campos esto es inviable a mano, y por eso existen los validadores de esquema.
Solución 3
ts
const original = {
fecha: new Date('2024-01-15T10:00:00Z'),
ausente: undefined,
vacio: null,
lista: [1, undefined, 3],
};
const texto = JSON.stringify(original);
// '{"fecha":"2024-01-15T10:00:00.000Z","vacio":null,"lista":[1,null,3]}'
const vuelta = JSON.parse(texto);CampoAl volverfechaCadena '2024-01-15T10:00:00.000Z'ausenteNo existe la propiedadvacionulllista[1, null, 3]
El campo undefined es indistinguible de uno ausente porque stringify no lo escribe en el texto. Al parsear no hay nada que leer, así que el objeto resultante simplemente no tiene esa clave. La información de que "existía y valía undefined" se perdió al serializar, no al parsear.
Solución 4
ts
const CAMPOS_SENSIBLES = new Set(['password', 'token', 'documento', 'tarjeta']);
function logSeguro(evento: string, payload: unknown): void {
const seguro = JSON.stringify(payload, (clave, valor) =>
CAMPOS_SENSIBLES.has(clave) ? '***' : valor,
);
console.log(JSON.stringify({ evento, payload: seguro }));
}El replacer se llama por cada par clave-valor de la estructura completa, así que un campo password anidado a cualquier profundidad queda enmascarado igual que uno de primer nivel.
Usar '***' en vez de undefined deja constancia de que el campo existía, cosa útil al depurar. Si prefieres que desaparezca por completo, devuelve undefined.
Conclusión
JSON.parse es el punto donde el sistema de tipos deja de proteger, porque devuelve any y any desactiva las comprobaciones de todo lo que toca.
La respuesta correcta no es asegurarle al compilador la forma que esperas, porque eso solo retrasa el fallo, sino tratar el resultado como unknown y obligar a que algo lo compruebe antes de usarlo.
A eso se suma saber qué no sobrevive al formato: las fechas vuelven como texto, undefined desaparece y las clases pierden sus métodos.
Artículos relacionados

Variables, constantes y tipos primitivos
Con esta lección empieza el bloque del sistema de tipos, y el punto de partida es el más pequeño posible: declarar una variable. Parece trivial, pero la forma de declararla decide dos cosas distintas. Qué se puede reasignar, y qué tipo infiere el compilador. Ambas tienen consecuencias que se arrastran por el resto del código. Nota sobre la terminología: en este artículo, declarar significa crear una variable con let, const o var. Asignar significa darle un valor, y reasignar significa cambiar el valor al que apunta una variable ya declarada.

Operadores de ausencia: ?., ?? y ??=
Lección 5 de 43 · El lenguaje: de JavaScript a TypeScript estricto

Recorrer datos: map, filter, find, reduce y Object.*
Lección 4 de 43 · El lenguaje: de JavaScript a TypeScript estricto