paint-brush
Beherrschen der typsicheren JSON-Serialisierung in TypeScriptvon@nodge
21,226 Lesungen
21,226 Lesungen

Beherrschen der typsicheren JSON-Serialisierung in TypeScript

von Maksim Zemskov11m2024/02/26
Read on Terminal Reader

Zu lang; Lesen

In diesem Artikel werden die Herausforderungen der Datenserialisierung in TypeScript bei Verwendung des JSON-Formats untersucht. Es konzentriert sich insbesondere auf die Mängel der Funktionen JSON.stringify und JSON.parse. Um diese Probleme zu beheben, wird die Verwendung des JSONCompatible-Typs empfohlen, um zu überprüfen, ob ein Typ T sicher in JSON serialisiert werden kann. Darüber hinaus empfiehlt es die Superstruct-Bibliothek zur sicheren Deserialisierung von JSON. Diese Methode verbessert die Typsicherheit und ermöglicht die Fehlererkennung während der Entwicklung.
featured image - Beherrschen der typsicheren JSON-Serialisierung in TypeScript
Maksim Zemskov HackerNoon profile picture
0-item
1-item
2-item


Fast jede Webanwendung erfordert eine Datenserialisierung. Dieses Bedürfnis entsteht in Situationen wie:


  • Datenübertragung über das Netzwerk (z. B. HTTP-Anfragen, WebSockets)
  • Einbetten von Daten in HTML (z. B. zur Flüssigkeitszufuhr)
  • Speichern von Daten in einem dauerhaften Speicher (wie LocalStorage)
  • Daten zwischen Prozessen teilen (wie Web Worker oder PostMessage)


In vielen Fällen können Datenverlust oder -beschädigung schwerwiegende Folgen haben. Daher ist es wichtig, einen praktischen und sicheren Serialisierungsmechanismus bereitzustellen, der dabei hilft, so viele Fehler wie möglich während der Entwicklungsphase zu erkennen. Für diese Zwecke ist es praktisch, JSON als Datenübertragungsformat und TypeScript für die statische Codeprüfung während der Entwicklung zu verwenden.


TypeScript dient als Obermenge von JavaScript, was die nahtlose Nutzung von Funktionen wie JSON.stringify und JSON.parse ermöglichen sollte, oder? Es stellt sich heraus, dass TypeScript trotz aller Vorteile von Natur aus nicht versteht, was JSON ist und welche Datentypen für die Serialisierung und Deserialisierung in JSON sicher sind.


Lassen Sie uns dies anhand eines Beispiels veranschaulichen.


Das Problem mit JSON in TypeScript

Stellen Sie sich zum Beispiel eine Funktion vor, die einige Daten in LocalStorage speichert. Da LocalStorage keine Objekte speichern kann, verwenden wir hier die JSON-Serialisierung:


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


Wir benötigen außerdem eine Funktion zum Abrufen der Daten von LocalStorage.


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


Was stimmt mit diesem Code nicht? Das erste Problem besteht darin, dass wir beim Wiederherstellen des Kommentars einen string anstelle von Date für das Feld updatedAt erhalten.


Dies liegt daran, dass JSON nur über vier primitive Datentypen ( null , string , number , boolean ) sowie Arrays und Objekte verfügt. Es ist nicht möglich, ein Date Objekt in JSON zu speichern, ebenso wie andere Objekte, die in JavaScript vorkommen: Funktionen, Map, Set usw.


Wenn JSON.stringify auf einen Wert stößt, der nicht im JSON-Format dargestellt werden kann, erfolgt eine Typumwandlung. Im Fall eines Date Objekts erhalten wir einen String, da das Date Objekt die toJson()- Methode implementiert, die einen String anstelle eines Date Objekts zurückgibt.


 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


Das zweite Problem besteht darin, dass die Funktion saveComment den Typ PostComment zurückgibt, bei dem das Datumsfeld vom Typ Date ist. Aber wir wissen bereits, dass wir anstelle von Date einen string Typ erhalten. TypeScript könnte uns helfen, diesen Fehler zu finden, aber warum nicht?


Es stellt sich heraus, dass die Funktion JSON.parse in der Standardbibliothek von TypeScript als (text: string) => any eingegeben wird. Aufgrund der Verwendung von any ist die Typprüfung grundsätzlich deaktiviert. In unserem Beispiel hat sich TypeScript einfach darauf verlassen, dass die Funktion einen PostComment zurückgeben würde, der ein Date Objekt enthält.


Dieses TypeScript-Verhalten ist unbequem und unsicher. Unsere Anwendung kann abstürzen, wenn wir versuchen, eine Zeichenfolge wie ein Date zu behandeln. Es könnte beispielsweise kaputt gehen, wenn wir comment.updatedAt.toLocaleDateString() aufrufen.


Tatsächlich könnten wir in unserem kleinen Beispiel einfach das Date Objekt durch einen numerischen Zeitstempel ersetzen, was für die JSON-Serialisierung gut funktioniert. In realen Anwendungen können die Datenobjekte jedoch umfangreich sein, Typen können an mehreren Stellen definiert werden und die Identifizierung eines solchen Fehlers während der Entwicklung kann eine herausfordernde Aufgabe sein.


Was wäre, wenn wir das JSON-Verständnis von TypeScript verbessern könnten?


Umgang mit Serialisierung

Lassen Sie uns zunächst herausfinden, wie Sie TypeScript verständlich machen, welche Datentypen sicher in JSON serialisiert werden können. Angenommen, wir möchten eine Funktion safeJsonStringify erstellen, bei der TypeScript das Eingabedatenformat überprüft, um sicherzustellen, dass es JSON-serialisierbar ist.


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


Der wichtigste Teil dieser Funktion ist der Typ JSONValue , der alle möglichen Werte darstellt, die im JSON-Format dargestellt werden können. Die Umsetzung ist ganz einfach:


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


Zuerst definieren wir den Typ JSONPrimitive , der alle primitiven JSON-Datentypen beschreibt. Wir schließen auch den undefined Typ ein, da bei der Serialisierung Schlüssel mit dem undefined Wert weggelassen werden. Während der Deserialisierung werden diese Schlüssel einfach nicht im Objekt angezeigt, was in den meisten Fällen dasselbe ist.


Als nächstes beschreiben wir den JSONValue Typ. Dieser Typ nutzt die Fähigkeit von TypeScript, rekursive Typen zu beschreiben, bei denen es sich um Typen handelt, die auf sich selbst verweisen. Hier kann JSONValue entweder ein JSONPrimitive , ein Array von JSONValue oder ein Objekt sein, bei dem alle Werte vom Typ JSONValue sind. Daher kann eine Variable dieses Typs JSONValue Arrays und Objekte mit unbegrenzter Verschachtelung enthalten. Die darin enthaltenen Werte werden auch auf Kompatibilität mit dem JSON-Format überprüft.


Jetzt können wir unsere Funktion safeJsonStringify anhand der folgenden Beispiele testen:


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


Alles scheint ordnungsgemäß zu funktionieren. Die Funktion ermöglicht es uns, das Datum als Zahl zu übergeben, erzeugt jedoch einen Fehler, wenn wir das Date Objekt übergeben.


Betrachten wir jedoch ein realistischeres Beispiel, bei dem die an die Funktion übergebenen Daten in einer Variablen gespeichert werden und einen beschriebenen Typ haben.


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


Jetzt wird es etwas knifflig. TypeScript erlaubt uns nicht, eine Variable vom Typ PostComment einem Funktionsparameter vom Typ JSONValue zuzuweisen, weil „Indexsignatur für Typ ‚string‘ im Typ ‚PostComment‘ fehlt“.


Was ist also eine Indexsignatur und warum fehlt sie? Erinnern Sie sich, wie wir Objekte beschrieben haben, die in das JSON-Format serialisiert werden können?


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


In diesem Fall ist [key: string] die Indexsignatur. Das bedeutet: „Dieses Objekt kann beliebige Schlüssel in Form von Zeichenfolgen haben, deren Werte den Typ JSONValue haben“. Es stellt sich also heraus, dass wir dem Typ PostComment eine Indexsignatur hinzufügen müssen, oder?


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


Dies würde bedeuten, dass der Kommentar beliebige Felder enthalten könnte, was normalerweise nicht das gewünschte Ergebnis beim Definieren von Datentypen in einer Anwendung ist.


Die eigentliche Lösung für das Problem mit der Indexsignatur kommt von Mapped Types , die eine rekursive Iteration über Felder ermöglichen, selbst für Typen, für die keine Indexsignatur definiert ist. In Kombination mit Generika ermöglicht diese Funktion die Konvertierung jedes Datentyps T in einen anderen Typ JSONCompatible<T> , der mit dem JSON-Format kompatibel ist.


 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;


Der Typ JSONCompatible<T> ist ein zugeordneter Typ, der prüft, ob ein bestimmter Typ T sicher in JSON serialisiert werden kann. Dazu wird jede Eigenschaft vom Typ T durchlaufen und Folgendes ausgeführt:


  1. Das T[P] extends JSONValue ? T[P] : ... Der bedingte Typ überprüft, ob der Typ der Eigenschaft mit dem JSONValue Typ kompatibel ist, um sicherzustellen, dass er sicher in JSON konvertiert werden kann. In diesem Fall bleibt der Typ der Eigenschaft unverändert.
  2. Das T[P] extends NotAssignableToJson ? never : ... Der bedingte Typ überprüft, ob der Typ der Eigenschaft nicht JSON zugewiesen werden kann. In diesem Fall wird der Typ der Eigenschaft in never konvertiert, wodurch die Eigenschaft effektiv aus dem endgültigen Typ herausgefiltert wird.
  3. Wenn keine dieser Bedingungen erfüllt ist, wird der Typ rekursiv überprüft, bis eine Schlussfolgerung gezogen werden kann. Auf diese Weise funktioniert es auch dann, wenn der Typ keine Indexsignatur hat.


Die unknown extends T ? never :... check at the begin wird verwendet, um zu verhindern, dass der unknown Typ in einen leeren Objekttyp {} konvertiert wird, der im Wesentlichen dem Typ „ any entspricht.


Ein weiterer interessanter Aspekt ist der Typ NotAssignableToJson . Es besteht aus zwei TypeScript-Primitiven (bigint und symbol) und dem Function Typ, der jede mögliche Funktion beschreibt. Der Function ist entscheidend für das Herausfiltern von Werten, die nicht JSON zugewiesen werden können. Dies liegt daran, dass jedes komplexe Objekt in JavaScript auf dem Objekttyp basiert und mindestens eine Funktion in seiner Prototypkette hat (z. B. toString() ). Der JSONCompatible Typ iteriert über alle diese Funktionen, sodass die Überprüfung der Funktionen ausreicht, um alles herauszufiltern, was nicht für JSON serialisierbar ist.


Nun verwenden wir diesen Typ in der Serialisierungsfunktion:


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


Jetzt verwendet die Funktion einen generischen Parameter T und akzeptiert das Argument JSONCompatible<T> . Dies bedeutet, dass data vom Typ T erforderlich sind, bei denen es sich um einen JSON-kompatiblen Typ handeln sollte. Jetzt können wir die Funktion mit Datentypen ohne Indexsignatur verwenden.


Die Funktion verwendet jetzt einen generischen Parameter T , der vom Typ JSONCompatible<T> erweitert wird. Dies bedeutet, dass data vom Typ T akzeptiert werden, bei denen es sich um einen JSON-kompatiblen Typ handeln sollte. Daher können wir die Funktion mit Datentypen verwenden, denen eine Indexsignatur fehlt.


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


Dieser Ansatz kann immer dann verwendet werden, wenn eine JSON-Serialisierung erforderlich ist, z. B. zum Übertragen von Daten über das Netzwerk, zum Einbetten von Daten in HTML, zum Speichern von Daten in localStorage, zum Übertragen von Daten zwischen Workern usw. Darüber hinaus kann der toJsonValue Helfer verwendet werden, wenn ein streng typisiertes Objekt ohne Einer Variablen vom Typ JSONValue muss eine Indexsignatur zugewiesen werden.


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


In diesem Beispiel können wir durch die Verwendung toJsonValue den Fehler umgehen, der mit der fehlenden Indexsignatur im PostComment -Typ zusammenhängt.


Umgang mit Deserialisierung

Bei der Deserialisierung ist die Herausforderung gleichzeitig einfacher und komplexer, da sie sowohl statische Analyseprüfungen als auch Laufzeitprüfungen für das Format der empfangenen Daten umfasst.


Aus der Perspektive des Typsystems von TypeScript ist die Herausforderung recht einfach. Betrachten wir das folgende Beispiel:


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


In diesem Fall ersetzen wir den Rückgabetyp any durch den Typ unknown . Warum unknown wählen? Im Wesentlichen kann ein JSON-String alles enthalten, nicht nur die Daten, die wir erwarten. Beispielsweise kann sich das Datenformat zwischen verschiedenen Anwendungsversionen ändern oder ein anderer Teil der App könnte Daten in denselben LocalStorage-Schlüssel schreiben. Daher ist unknown die sicherste und präziseste Wahl.


Allerdings ist das Arbeiten mit dem unknown Typ weniger komfortabel als die bloße Angabe des gewünschten Datentyps. Neben der Typumwandlung gibt es mehrere Möglichkeiten, den unknown Typ in den erforderlichen Datentyp umzuwandeln. Eine dieser Methoden ist die Verwendung der Superstruct- Bibliothek, um Daten zur Laufzeit zu validieren und detaillierte Fehler auszulösen, wenn die Daten ungültig sind.


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


Hier fungiert die create als Typwächter und schränkt den Typ auf die gewünschte Comment ein. Folglich müssen wir den Rückgabetyp nicht mehr manuell angeben.


Die Implementierung einer sicheren Deserialisierungsoption ist nur die halbe Miete. Ebenso wichtig ist es, nicht zu vergessen, es zu verwenden, wenn man die nächste Aufgabe im Projekt in Angriff nimmt. Dies stellt eine besondere Herausforderung dar, wenn ein großes Team an dem Projekt arbeitet, da es schwierig sein kann, sicherzustellen, dass alle Vereinbarungen und Best Practices eingehalten werden.


Typescript-eslint kann bei dieser Aufgabe helfen. Dieses Tool hilft dabei, alle Fälle any Nutzung zu identifizieren. Insbesondere können alle Verwendungen von JSON.parse gefunden und sichergestellt werden, dass das Format der empfangenen Daten überprüft wird. Weitere Informationen zum Entfernen des Typs any “ in einer Codebasis finden Sie im Artikel „Making TypeScript Truly „Strongly Typed““ .


Abschluss

Hier sind die letzten Dienstprogrammfunktionen und -typen, die zur sicheren JSON-Serialisierung und -Deserialisierung beitragen sollen. Diese können Sie im vorbereiteten TS Playground testen.


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


Diese können in jeder Situation verwendet werden, in der eine JSON-Serialisierung erforderlich ist.


Ich wende diese Strategie bereits seit mehreren Jahren in meinen Projekten an und sie hat ihre Wirksamkeit durch die rechtzeitige Erkennung potenzieller Fehler während der Anwendungsentwicklung unter Beweis gestellt.


Ich hoffe, dieser Artikel hat Ihnen einige neue Erkenntnisse geliefert. Vielen Dank fürs Lesen!

Nützliche Links