Presque toutes les applications Web nécessitent la sérialisation des données. Ce besoin survient dans des situations telles que :
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.
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 ?
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 :
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é.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.
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
.
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" .
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!