paint-brush
Maîtriser la sérialisation JSON Type-Safe dans TypeScriptby@nodge
12,526
12,526

Maîtriser la sérialisation JSON Type-Safe dans TypeScript

Maksim Zemskov11m2024/02/26
Read on Terminal Reader

Cet article explore les défis de la sérialisation des données dans TypeScript lors de l'utilisation du format JSON. Il se concentre particulièrement sur les lacunes des fonctions JSON.stringify et JSON.parse. Pour résoudre ces problèmes, il suggère l'utilisation du type JSONCompatible pour vérifier si un type T peut être sérialisé en toute sécurité en JSON. De plus, il recommande la bibliothèque Superstruct pour une désérialisation sécurisée à partir de JSON. Cette méthode améliore la sécurité des types et permet la détection des erreurs lors du développement.
featured image - Maîtriser la sérialisation JSON Type-Safe dans TypeScript
Maksim Zemskov HackerNoon profile picture
0-item
1-item
2-item


Presque toutes les applications Web nécessitent la sérialisation des données. Ce besoin survient dans des situations telles que :


  • Transfert de données sur le réseau (par exemple requêtes HTTP, WebSockets)
  • Intégrer des données en HTML (pour l'hydratation, par exemple)
  • Stockage des données dans un stockage persistant (comme LocalStorage)
  • Partage de données entre processus (comme les Web Workers ou postMessage)


Dans de nombreux cas, la perte ou la corruption de données peut entraîner de graves conséquences. Il est donc essentiel de fournir un mécanisme de sérialisation pratique et sûr permettant de détecter autant d'erreurs que possible pendant la phase de développement. À ces fins, il est pratique d'utiliser JSON comme format de transfert de données et TypeScript pour la vérification du code statique pendant le développement.


TypeScript sert de sur-ensemble de JavaScript, ce qui devrait permettre une utilisation transparente de fonctions telles que JSON.stringify et JSON.parse , n'est-ce pas ? Il s'avère que, malgré tous ses avantages, TypeScript ne comprend pas naturellement ce qu'est JSON ni quels types de données sont sûrs pour la sérialisation et la désérialisation en JSON.


Illustrons cela avec un exemple.


Le problème avec JSON dans TypeScript

Prenons, par exemple, une fonction qui enregistre certaines données dans LocalStorage. Comme LocalStorage ne peut pas stocker d'objets, nous utilisons ici la sérialisation JSON :


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


Nous aurons également besoin d'une fonction pour récupérer les données de LocalStorage.


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


Quel est le problème avec ce code ? Le premier problème est que lors de la restauration du commentaire, nous obtiendrons un type string au lieu de Date pour le champ updatedAt .


Cela se produit parce que JSON n'a que quatre types de données primitifs ( null , string , number , boolean ), ainsi que des tableaux et des objets. Il n'est pas possible de sauvegarder un objet Date en JSON, ainsi que d'autres objets que l'on trouve en JavaScript : fonctions, Map, Set, etc.


Lorsque JSON.stringify rencontre une valeur qui ne peut pas être représentée au format JSON, une conversion de type se produit. Dans le cas d'un objet Date , nous obtenons une chaîne car l'objet Date implémente la méthode toJson() , qui renvoie une chaîne au lieu d'un objet 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


Le deuxième problème est que la fonction saveComment renvoie le type PostComment , dans lequel le champ date est de type Date . Mais nous savons déjà qu'au lieu de Date , nous recevrons un type string . TypeScript pourrait nous aider à trouver cette erreur, mais pourquoi pas ?


Il s'avère que dans la bibliothèque standard de TypeScript, la fonction JSON.parse est saisie comme (text: string) => any . En raison de l'utilisation de any , la vérification de type est essentiellement désactivée. Dans notre exemple, TypeScript nous a simplement cru sur parole que la fonction renverrait un PostComment contenant un objet Date .


Ce comportement TypeScript est peu pratique et dangereux. Notre application peut planter si nous essayons de traiter une chaîne comme un objet Date . Par exemple, cela pourrait échouer si nous appelons comment.updatedAt.toLocaleDateString() .


En effet, dans notre petit exemple, nous pourrions simplement remplacer l'objet Date par un horodatage numérique, ce qui fonctionne bien pour la sérialisation JSON. Cependant, dans les applications réelles, les objets de données peuvent être volumineux, les types peuvent être définis à plusieurs emplacements et l'identification d'une telle erreur pendant le développement peut s'avérer une tâche difficile.


Et si nous pouvions améliorer la compréhension de TypeScript de JSON ?


Gérer la sérialisation

Pour commencer, voyons comment faire comprendre à TypeScript quels types de données peuvent être sérialisés en toute sécurité dans JSON. Supposons que nous souhaitions créer une fonction safeJsonStringify , où TypeScript vérifiera le format des données d'entrée pour garantir qu'il est sérialisable en JSON.


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


Dans cette fonction, la partie la plus importante est le type JSONValue , qui représente toutes les valeurs possibles pouvant être représentées au format JSON. La mise en œuvre est assez simple :


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


Tout d’abord, nous définissons le type JSONPrimitive , qui décrit tous les types de données JSON primitifs. Nous incluons également le type undefined basé sur le fait que lors de la sérialisation, les clés avec la valeur undefined seront omises. Lors de la désérialisation, ces clés n'apparaîtront tout simplement pas dans l'objet, ce qui revient dans la plupart des cas à la même chose.


Ensuite, nous décrivons le type JSONValue . Ce type utilise la capacité de TypeScript à décrire les types récursifs, qui sont des types qui font référence à eux-mêmes. Ici, JSONValue peut être un JSONPrimitive , un tableau de JSONValue ou un objet où toutes les valeurs sont du type JSONValue . Par conséquent, une variable de ce type JSONValue peut contenir des tableaux et des objets avec une imbrication illimitée. La compatibilité des valeurs contenues dans celles-ci sera également vérifiée avec le format JSON.


Nous pouvons maintenant tester notre fonction safeJsonStringify à l'aide des exemples suivants :


 // 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(); });


Tout semble fonctionner correctement. La fonction nous permet de transmettre la date sous forme de nombre mais génère une erreur si nous passons l'objet Date .


Mais considérons un exemple plus réaliste, dans lequel les données transmises à la fonction sont stockées dans une variable et ont un type décrit.


 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);


Maintenant, les choses deviennent un peu délicates. TypeScript ne nous permet pas d'attribuer une variable de type PostComment à un paramètre de fonction de type JSONValue , car "La signature d'index pour le type 'string' est manquante dans le type 'PostComment'".


Alors, qu’est-ce qu’une signature d’index et pourquoi manque-t-elle ? Rappelez-vous comment nous avons décrit les objets pouvant être sérialisés au format JSON ?


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


Dans ce cas, [key: string] est la signature d'index. Cela signifie "cet objet peut avoir n'importe quelle clé sous forme de chaînes dont les valeurs ont le type JSONValue ". Il s'avère donc que nous devons ajouter une signature d'index au type PostComment , n'est-ce pas ?


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


Cela impliquerait que le commentaire pourrait contenir des champs arbitraires, ce qui n'est généralement pas le résultat souhaité lors de la définition de types de données dans une application.


La véritable solution au problème de la signature d'index vient des Mapped Types , qui permettent une itération récursive sur les champs, même pour les types pour lesquels aucune signature d'index n'est définie. Combinée aux génériques, cette fonctionnalité permet de convertir n'importe quel type de données T en un autre type JSONCompatible<T> , compatible avec le format 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;


Le type JSONCompatible<T> est un type mappé qui vérifie si un type T donné peut être sérialisé en toute sécurité dans JSON. Pour ce faire, il parcourt chaque propriété de type T et effectue les opérations suivantes :


  1. Le T[P] extends JSONValue ? T[P] : ... le type conditionnel vérifie si le type de la propriété est compatible avec le type JSONValue , garantissant qu'il peut être converti en toute sécurité en JSON. Lorsque c'est le cas, le type de propriété reste inchangé.
  2. Le T[P] extends NotAssignableToJson ? never : ... le type conditionnel vérifie si le type de la propriété n'est pas attribuable à JSON. Dans ce cas, le type de la propriété est converti en never , filtrant ainsi la propriété du type final.
  3. Si aucune de ces conditions n’est remplie, le type est vérifié récursivement jusqu’à ce qu’une conclusion puisse être tirée. De cette façon, cela fonctionne même si le type n'a pas de signature d'index.


L' unknown extends T ? never :... check au début est utilisé pour empêcher le type unknown d'être converti en un type d'objet vide {} , qui est essentiellement équivalent à any type.


Un autre aspect intéressant est le type NotAssignableToJson . Il se compose de deux primitives TypeScript (bigint et symbol) et du type Function , qui décrit toute fonction possible. Le type Function est crucial pour filtrer toutes les valeurs qui ne sont pas attribuables à JSON. En effet, tout objet complexe en JavaScript est basé sur le type Object et possède au moins une fonction dans sa chaîne de prototypes (par exemple, toString() ). Le type JSONCompatible parcourt toutes ces fonctions, donc la vérification des fonctions est suffisante pour filtrer tout ce qui n'est pas sérialisable en JSON.


Utilisons maintenant ce type dans la fonction de sérialisation :


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


Désormais, la fonction utilise un paramètre générique T et accepte l'argument JSONCompatible<T> . Cela signifie qu'il prend un argument data de type T , qui doit être un type compatible JSON. Nous pouvons désormais utiliser la fonction avec des types de données sans signature d'index.


La fonction utilise désormais un paramètre générique T qui s'étend du type JSONCompatible<T> . Cela signifie qu'il accepte un argument data de type T , qui doit être un type compatible JSON. En conséquence, nous pouvons utiliser la fonction avec des types de données dépourvus de signature d’index.


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


Cette approche peut être utilisée chaque fois que la sérialisation JSON est nécessaire, comme le transfert de données sur le réseau, l'intégration de données dans HTML, le stockage de données dans localStorage, le transfert de données entre travailleurs, etc. De plus, l'assistant toJsonValue peut être utilisé lorsqu'un objet strictement typé sans une signature d'index doit être attribuée à une variable de type JSONValue .


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


Dans cet exemple, l'utilisation toJsonValue nous permet de contourner l'erreur liée à la signature d'index manquante dans le type PostComment .


Gérer la désérialisation

En ce qui concerne la désérialisation, le défi est à la fois plus simple et plus complexe car il implique à la fois des contrôles d'analyse statique et des contrôles d'exécution pour le format des données reçues.


Du point de vue du système de types de TypeScript, le défi est assez simple. Considérons l'exemple suivant :


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


Dans ce cas, nous remplaçons le type de retour any par le type unknown . Pourquoi choisir unknown ? Essentiellement, une chaîne JSON peut contenir n'importe quoi, pas seulement les données que nous espérons recevoir. Par exemple, le format des données peut changer entre différentes versions de l'application ou une autre partie de l'application peut écrire des données sur la même clé LocalStorage. Par conséquent, unknown est le choix le plus sûr et le plus précis.


Cependant, travailler avec un type unknown est moins pratique que simplement spécifier le type de données souhaité. Outre la conversion de type, il existe plusieurs façons de convertir le type unknown en type de données requis. L'une de ces méthodes consiste à utiliser la bibliothèque Superstruct pour valider les données au moment de l'exécution et générer des erreurs détaillées si les données ne sont pas valides.


 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; }


Ici, la fonction create agit comme une protection de type, limitant le type à l'interface Comment souhaitée. Par conséquent, nous n’avons plus besoin de spécifier manuellement le type de retour.


La mise en œuvre d’une option de désérialisation sécurisée ne représente que la moitié de l’histoire. Il est également crucial de ne pas oublier de l'utiliser lors de la prochaine tâche du projet. Cela devient particulièrement difficile si une grande équipe travaille sur le projet, car il peut être difficile de garantir que tous les accords et les meilleures pratiques sont respectés.


Typescript-eslint peut vous aider dans cette tâche. Cet outil permet d'identifier tous les cas any utilisation dangereuse. Plus précisément, toutes les utilisations de JSON.parse peuvent être trouvées et il est possible de garantir que le format des données reçues est vérifié. Pour en savoir plus sur l'élimination de any type dans une base de code, consultez l'article Making TypeScript Truly "Strongly Typed" .


Conclusion

Voici les fonctions et types d'utilitaires finaux conçus pour faciliter la sérialisation et la désérialisation JSON en toute sécurité. Vous pouvez les tester dans le TS Playground préparé.


 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); }


Ceux-ci peuvent être utilisés dans toutes les situations où la sérialisation JSON est nécessaire.


J'utilise cette stratégie dans mes projets depuis plusieurs années maintenant et elle a démontré son efficacité en détectant rapidement les erreurs potentielles lors du développement d'applications.


J'espère que cet article vous a fourni de nouvelles informations. Merci pour la lecture!

Liens utiles