paint-brush
Dominar la serialización JSON con seguridad de tipos en TypeScriptpor@nodge
23,893 lecturas
23,893 lecturas

Dominar la serialización JSON con seguridad de tipos en TypeScript

por Maksim Zemskov11m2024/02/26
Read on Terminal Reader

Demasiado Largo; Para Leer

Este artículo explora los desafíos de la serialización de datos en TypeScript cuando se usa el formato JSON. Se centra especialmente en las deficiencias de las funciones JSON.stringify y JSON.parse. Para abordar estos problemas, sugiere el uso del tipo JSONCompatible para verificar si un tipo T se puede serializar de forma segura en JSON. Además, recomienda la biblioteca Superstruct para una deserialización segura desde JSON. Este método mejora la seguridad de tipos y permite la detección de errores durante el desarrollo.
featured image - Dominar la serialización JSON con seguridad de tipos en TypeScript
Maksim Zemskov HackerNoon profile picture
0-item
1-item
2-item


Casi todas las aplicaciones web requieren serialización de datos. Esta necesidad surge en situaciones como:


  • Transferir datos a través de la red (por ejemplo, solicitudes HTTP, WebSockets)
  • Incrustar datos en HTML (para hidratación, por ejemplo)
  • Almacenar datos en un almacenamiento persistente (como LocalStorage)
  • Compartir datos entre procesos (como trabajadores web o postMessage)


En muchos casos, la pérdida o corrupción de datos puede tener consecuencias graves, por lo que es esencial proporcionar un mecanismo de serialización conveniente y seguro que ayude a detectar tantos errores como sea posible durante la etapa de desarrollo. Para estos fines, es conveniente utilizar JSON como formato de transferencia de datos y TypeScript para verificar el código estático durante el desarrollo.


TypeScript sirve como un superconjunto de JavaScript, lo que debería permitir el uso fluido de funciones como JSON.stringify y JSON.parse , ¿verdad? Resulta que, a pesar de todos sus beneficios, TypeScript no entiende naturalmente qué es JSON y qué tipos de datos son seguros para la serialización y deserialización en JSON.


Ilustremos esto con un ejemplo.


El problema con JSON en TypeScript

Considere, por ejemplo, una función que guarda algunos datos en LocalStorage. Como LocalStorage no puede almacenar objetos, aquí utilizamos la serialización JSON:


 interface PostComment { authorId: string; text: string; updatedAt: Date; } function saveComment(comment: PostComment) { const serializedComment = JSON.stringify(comment); localStorage.setItem('draft', serializedComment); }


También necesitaremos una función para recuperar los datos de LocalStorage.


 function restoreComment(): PostComment | undefined { const text = localStorage.getItem('draft'); return text ? JSON.parse(text) : undefined; }


¿Qué hay de malo en este código? El primer problema es que al restaurar el comentario, obtendremos un tipo string en lugar de Date para el campo updatedAt .


Esto sucede porque JSON solo tiene cuatro tipos de datos primitivos ( null , string , number , boolean ), así como matrices y objetos. No es posible guardar un objeto Date en JSON, así como otros objetos que se encuentran en JavaScript: funciones, Map, Set, etc.


Cuando JSON.stringify encuentra un valor que no se puede representar en formato JSON, se produce la conversión de tipos. En el caso de un objeto Date , obtenemos una cadena porque el objeto Date implementa el método toJson() , que devuelve una cadena en lugar de un objeto Date .


 const date = new Date('August 19, 1975 23:15:30 UTC'); const jsonDate = date.toJSON(); console.log(jsonDate); // Expected output: "1975-08-19T23:15:30.000Z" const isEqual = date.toJSON() === JSON.stringify(date); console.log(isEqual); // Expected output: true


El segundo problema es que la función saveComment devuelve el tipo PostComment , en el que el campo de fecha es de tipo Date . Pero ya sabemos que en lugar de Date , recibiremos un tipo string . TypeScript podría ayudarnos a encontrar este error, pero ¿por qué no?


Resulta que, en la biblioteca estándar de TypeScript, la función JSON.parse se escribe como (text: string) => any . Debido al uso de any , la verificación de tipos está esencialmente deshabilitada. En nuestro ejemplo, TypeScript simplemente tomó nuestra palabra de que la función devolvería un PostComment que contiene un objeto Date .


Este comportamiento de TypeScript es inconveniente e inseguro. Nuestra aplicación puede fallar si intentamos tratar una cadena como un objeto Date . Por ejemplo, podría fallar si llamamos comment.updatedAt.toLocaleDateString() .


De hecho, en nuestro pequeño ejemplo, podríamos simplemente reemplazar el objeto Date con una marca de tiempo numérica, lo que funciona bien para la serialización JSON. Sin embargo, en aplicaciones reales, los objetos de datos pueden ser extensos, los tipos se pueden definir en múltiples ubicaciones e identificar dicho error durante el desarrollo puede ser una tarea desafiante.


¿Qué pasaría si pudiéramos mejorar la comprensión de JSON por parte de TypeScript?


Lidiando con la serialización

Para empezar, descubramos cómo hacer que TypeScript comprenda qué tipos de datos se pueden serializar de forma segura en JSON. Supongamos que queremos crear una función safeJsonStringify , donde TypeScript verificará el formato de los datos de entrada para garantizar que sea JSON serializable.


 function safeJsonStringify(data: JSONValue) { return JSON.stringify(data); }


En esta función, la parte más importante es el tipo JSONValue , que representa todos los valores posibles que se pueden representar en formato JSON. La implementación es bastante sencilla:


 type JSONPrimitive = string | number | boolean | null | undefined; type JSONValue = JSONPrimitive | JSONValue[] | { [key: string]: JSONValue; };


Primero, definimos el tipo JSONPrimitive , que describe todos los tipos de datos JSON primitivos. También incluimos el tipo undefined basándonos en el hecho de que cuando se serializan, se omitirán las claves con el valor undefined . Durante la deserialización, estas claves simplemente no aparecerán en el objeto, lo que en la mayoría de los casos es lo mismo.


A continuación, describimos el tipo JSONValue . Este tipo utiliza la capacidad de TypeScript para describir tipos recursivos, que son tipos que se refieren a sí mismos. Aquí, JSONValue puede ser un JSONPrimitive , una matriz de JSONValue o un objeto donde todos los valores son del tipo JSONValue . Como resultado, una variable de este tipo JSONValue puede contener matrices y objetos con anidamiento ilimitado. También se comprobará la compatibilidad de los valores dentro de estos con el formato JSON.


Ahora podemos probar nuestra función safeJsonStringify usando los siguientes ejemplos:


 // No errors safeJsonStringify({ updatedAt: Date.now() }); // Yields an error: // Argument of type '{ updatedAt: Date; }' is not assignable to parameter of type 'JSONValue'. // Types of property 'updatedAt' are incompatible. // Type 'Date' is not assignable to type 'JSONValue'. safeJsonStringify({ updatedAt: new Date(); });


Todo parece funcionar correctamente. La función nos permite pasar la fecha como un número pero arroja un error si pasamos el objeto Date .


Pero consideremos un ejemplo más realista, en el que los datos pasados a la función se almacenan en una variable y tienen un tipo descrito.


 interface PostComment { authorId: string; text: string; updatedAt: number; }; const comment: PostComment = {...}; // Yields an error: // Argument of type 'PostComment' is not assignable to parameter of type 'JSONValue'. // Type 'PostComment' is not assignable to type '{ [key: string]: JSONValue; }'. // Index signature for type 'string' is missing in type 'PostComment'. safeJsonStringify(comment);


Ahora las cosas se están poniendo un poco complicadas. TypeScript no nos permitirá asignar una variable de tipo PostComment a un parámetro de función de tipo JSONValue , porque "Falta la firma de índice para el tipo 'cadena' en el tipo 'PostComment'".


Entonces, ¿qué es una firma de índice y por qué falta? ¿Recuerda cómo describimos los objetos que se pueden serializar en formato JSON?


 type JSONValue = { [key: string]: JSONValue; };


En este caso, [key: string] es la firma del índice. Significa "este objeto puede tener cualquier clave en forma de cadena, cuyos valores sean del tipo JSONValue ". Entonces resulta que necesitamos agregar una firma de índice al tipo PostComment , ¿verdad?


 interface PostComment { authorId: string; text: string; updatedAt: number; // Don't do this: [key: string]: JSONValue; };


Hacerlo implicaría que el comentario podría contener campos arbitrarios, lo que normalmente no es el resultado deseado al definir tipos de datos en una aplicación.


La verdadera solución al problema con la firma de índice proviene de Mapped Types , que permiten iterar recursivamente sobre campos, incluso para tipos que no tienen una firma de índice definida. Combinada con los genéricos, esta característica permite convertir cualquier tipo de datos T en otro tipo JSONCompatible<T> , que sea compatible con el formato JSON.


 type JSONCompatible<T> = unknown extends T ? never : { [P in keyof T]: T[P] extends JSONValue ? T[P] : T[P] extends NotAssignableToJson ? never : JSONCompatible<T[P]>; }; type NotAssignableToJson = | bigint | symbol | Function;


El tipo JSONCompatible<T> es un tipo asignado que inspecciona si un tipo T determinado se puede serializar de forma segura en JSON. Lo hace iterando sobre cada propiedad en el tipo T y haciendo lo siguiente:


  1. ¿El T[P] extends JSONValue ? T[P] : ... el tipo condicional verifica si el tipo de propiedad es compatible con el tipo JSONValue , asegurando que se pueda convertir de forma segura a JSON. Cuando este es el caso, el tipo de propiedad permanece sin cambios.
  2. ¿El T[P] extends NotAssignableToJson ? never : ... el tipo condicional verifica si el tipo de propiedad no se puede asignar a JSON. En este caso, el tipo de propiedad se convierte a never , filtrando efectivamente la propiedad del tipo final.
  3. Si no se cumple ninguna de estas condiciones, el tipo se verifica recursivamente hasta que se pueda llegar a una conclusión. De esta manera funciona incluso si el tipo no tiene una firma de índice.


La unknown extends T ? never :... check al principio se usa para evitar que el tipo unknown se convierta en un tipo de objeto vacío {} , que es esencialmente equivalente a any tipo.


Otro aspecto interesante es el tipo NotAssignableToJson . Consta de dos primitivas de TypeScript (bigint y símbolo) y el tipo Function , que describe cualquier función posible. El tipo Function es crucial para filtrar cualquier valor que no se pueda asignar a JSON. Esto se debe a que cualquier objeto complejo en JavaScript se basa en el tipo de objeto y tiene al menos una función en su cadena de prototipo (por ejemplo, toString() ). El tipo JSONCompatible itera sobre todas esas funciones, por lo que verificar las funciones es suficiente para filtrar todo lo que no sea serializable a JSON.


Ahora, usemos este tipo en la función de serialización:


 function safeJsonStringify<T>(data: JSONCompatible<T>) { return JSON.stringify(data); }


Ahora, la función usa un parámetro genérico T y acepta el argumento JSONCompatible<T> . Esto significa que toma un argumento data de tipo T , que debería ser un tipo compatible con JSON. Ahora podemos usar la función con tipos de datos sin firma de índice.


La función ahora usa un parámetro genérico T que se extiende desde el tipo JSONCompatible<T> . Esto significa que acepta un argumento data de tipo T , que debería ser un tipo compatible con JSON. Como resultado, podemos utilizar la función con tipos de datos que carecen de una firma de índice.


 interface PostComment { authorId: string; text: string; updatedAt: number; } function saveComment(comment: PostComment) { const serializedComment = safeJsonStringify(comment); localStorage.setItem('draft', serializedComment); }


Este enfoque se puede utilizar siempre que sea necesaria la serialización JSON, como transferir datos a través de la red, incrustar datos en HTML, almacenar datos en localStorage, transferir datos entre trabajadores, etc. Además, el asistente toJsonValue se puede utilizar cuando un objeto estrictamente tipado sin Es necesario asignar una firma de índice a una variable de tipo JSONValue .


 function toJsonValue<T>(value: JSONCompatible<T>): JSONValue { return value; } const comment: PostComment = {...}; const data: JSONValue = { comment: toJsonValue(comment) };


En este ejemplo, usar toJsonValue nos permite evitar el error relacionado con la firma de índice que falta en el tipo PostComment .


Lidiando con la deserialización

Cuando se trata de deserialización, el desafío es a la vez más simple y más complejo porque implica comprobaciones de análisis estático y comprobaciones en tiempo de ejecución para el formato de los datos recibidos.


Desde la perspectiva del sistema de tipos de TypeScript, el desafío es bastante simple. Consideremos el siguiente ejemplo:


 function safeJsonParse(text: string) { return JSON.parse(text) as unknown; } const data = JSON.parse(text); // ^? unknown


En este caso, sustituiremos any tipo de retorno por el tipo unknown . ¿Por qué elegir unknown ? Básicamente, una cadena JSON podría contener cualquier cosa, no sólo los datos que esperamos recibir. Por ejemplo, el formato de los datos podría cambiar entre diferentes versiones de la aplicación u otra parte de la aplicación podría escribir datos en la misma clave LocalStorage. Por tanto, unknown es la opción más segura y precisa.


Sin embargo, trabajar con el tipo unknown es menos conveniente que simplemente especificar el tipo de datos deseado. Además de la conversión de tipos, existen varias formas de convertir el tipo unknown en el tipo de datos requerido. Uno de esos métodos es utilizar la biblioteca Superstruct para validar datos en tiempo de ejecución y generar errores detallados si los datos no son válidos.


 import { create, object, number, string } from 'superstruct'; const PostComment = object({ authorId: string(), text: string(), updatedAt: number(), }); // Note: we no longer need to manually specify the return type function restoreDraft() { const text = localStorage.getItem('draft'); return text ? create(JSON.parse(text), PostComment) : undefined; }


Aquí, la función create actúa como protección de tipo, limitando el tipo a la interfaz Comment deseada. En consecuencia, ya no necesitamos especificar manualmente el tipo de devolución.


Implementar una opción de deserialización segura es sólo la mitad de la historia. Es igualmente crucial no olvidar usarlo al abordar la siguiente tarea del proyecto. Esto se vuelve particularmente desafiante si un equipo grande está trabajando en el proyecto, ya que garantizar que se sigan todos los acuerdos y las mejores prácticas puede resultar difícil.


Typescript-eslint puede ayudar en esta tarea. Esta herramienta ayuda a identificar todos los casos de uso any . Específicamente, se pueden encontrar todos los usos de JSON.parse y se puede garantizar que se verifique el formato de los datos recibidos. Puede leer más sobre cómo deshacerse de any tipo en una base de código en el artículo Cómo hacer que TypeScript sea verdaderamente "fuertemente tipificado" .


Conclusión

Estas son las funciones y tipos de utilidades finales diseñados para ayudar en la serialización y deserialización JSON seguras. Puedes probarlos en el TS Playground preparado.


 type JSONPrimitive = string | number | boolean | null | undefined; type JSONValue = JSONPrimitive | JSONValue[] | { [key: string]: JSONValue; }; type NotAssignableToJson = | bigint | symbol | Function; type JSONCompatible<T> = unknown extends T ? never : { [P in keyof T]: T[P] extends JSONValue ? T[P] : T[P] extends NotAssignableToJson ? never : JSONCompatible<T[P]>; }; function toJsonValue<T>(value: JSONCompatible<T>): JSONValue { return value; } function safeJsonStringify<T>(data: JSONCompatible<T>) { return JSON.stringify(data); } function safeJsonParse(text: string): unknown { return JSON.parse(text); }


Se pueden utilizar en cualquier situación en la que sea necesaria la serialización JSON.


Llevo varios años utilizando esta estrategia en mis proyectos y ha demostrado su eficacia al detectar rápidamente posibles errores durante el desarrollo de la aplicación.


Espero que este artículo le haya proporcionado algunas ideas nuevas. ¡Gracias por leer!

Enlaces útiles