Fast jede Webanwendung erfordert eine Datenserialisierung. Dieses Bedürfnis entsteht in Situationen wie:
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.
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?
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:
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.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.
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.
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““ .
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!