Datenvalidierung mit Typescript und Superstruct

In komplexen Softwaresystemen werden regelmäßig Daten zwischen Komponenten ausgetauscht. Oft passiert das in REST-Abfragen, Nachrichten-Queues, Publish/Subscribe-Services, oder ganz klassisch als JSON- oder CSV-Datei.

Die weitere Verwendung der Daten ist dann eine mögliche Fehlerquelle, falls unerwartete Daten empfangen und ungeprüft verarbeitet werden. Beispielsweise könnte ein String fälschlicher Weise leer sein, oder ein Datumsfeld ist nicht lesbar, da es nicht einem vereinbarten Format entspricht. Den Fehlerquellen vorzubeugen ist zwar zunächst die Aufgabe der Entwickler:innen, hängt aber auch von den technischen Möglichkeiten ab. Wie eine einfache und trotzdem sichere Validierung gelingen kann, soll im Folgenden gezeigt werden.

Herkömmliches Vorgehen

Initial werden Daten oft als JSON-String entgegengenommen. In Typescript steht zunächst die Funktion JSON.parse aus der Javascript Standardbibliothek zur Verfügung, um aus dem String ein Objekt zu erhalten. Das Resultat ist dann aber komplett untypisiert. Die einzig stattfindende Validierung ist, ob ein Objekt aus dem String gebaut werden kann. Ohne die Nutzung von weiteren Bibliotheken müsste jede weitere Valierung per Hand implementiert werden.

Angenommen, man habe einen einfachen Typ Movie:

				
					type Movie = {
  title: string;
  yearOfRelease: number;
};
				
			

Wenn wir nun ein Movie Objekt als JSON-String bekommen, können wir es mit JSON.parse parsen:

				
					const inputString = `{"title": "Office Space", "yearOfRelease": 1999}`;
const movie: Movie = JSON.parse(inputString);
console.log(`parsed: ${movie}`);
				
			

Wie in dem Beispiel könnte man für die movie Konstante eine Typ-Annotation nutzen, da wir ein Objekt vom Typ Movie erwarten. Da JSON.parse den Typ any zurückgibt ist das immer erlaubt und löst keinen Fehler aus. Gleichzeitig ist es keine gute Idee. Sobald any genutzt wird findet keine Typvalidierung statt.

In dem folgenden Beispiel würde man dadurch Laufzeitfehler auslösen, sobald der JSON-String kein gültiges Movie Objekt darstellt und man versuchen würde, das Ergebnis wie ein Movie Objekt zu benutzen.

				
					const inputString = `{"yearOfRelease": 1999}`;
const movie: Movie = JSON.parse(inputString);
const title: string = movie.title; // => undefined
movieRepository.findByName(movie); // => Exception ... is undefined!
				
			

Bibliotheken helfen bei der Validierung

Um dieser Kategorie von Problemen vorzubeugen, gibt es verschiedene Bibliotheken für die Validierung von Objekten. Dabei gibt es zwei unterschiedliche Arten von Bibliotheken: Entweder validieren sie bereits das JSON-Schema oder das daraus abgeleitete Javascript-Objekt.

JSON-Schemas sind durch die Internet Engineering Task Force standardisiert und haben den Vorteil, dass sie portabel sind. Man könnte dadurch z.B. das gleiche Schema für Backend und Fontend nutzen, selbst wenn dabei eine unterschiedliche Programmiersprache genutzt wird.

Bibliotheken, die nicht direkt mit dem JSON-Schema Standard arbeiten, sondern darauf spezialisiert sind, konkrete Javascript-Objekte zu validieren, bieten dafür eine übersichtlichere API und oftmals mehr Funktionen an.

Basissetup mit Superstruct

Eine Bibliothek, die uns dabei besonders positiv augefallen ist, ist superstruct . Mit superstruct ist es sehr einfach, ein Schema zu erstellen und JavaScript-Objekte darauf zu testen. Dadurch könnte unser Beispiel so aussehen:

				
					import {is, number, object, string } from "superstruct";

type Movie = {
  title: string;
  yearOfRelease: number;
};

const movieSchema = object({
  title: string(),
  yearOfRelease: number()
});

const inputString = `{"title": "Office Space"}`;
const movie: Movie = JSON.parse(inputString);
const r = is(movie, movieSchema);

if (r) {
  console.log("All good :)");
} else {
  console.log("Object is invalid according to the schema :(");
}
				
			

Die Funktion is prüft, ob das Object dem Schema entspricht, und gibt dann true oder false zurück. Alternativ könnte man mit assert auch eine Exception werfen.

				
					import { assert } from "superstruct";
const inputString = `{"title": "Office Space"}`;
const movie: Movie = JSON.parse(inputString);

// wird einen 'StructError' werfen
try {
  assert(movie, movieSchema);
} catch (error) {
  console.log(error);
}
				
			

Ein Vorteil von superstruct ist hier, dass die Fehlermeldung sehr gut lesbar ist. In diesem Beispiel wäre sie: StructError: At path: playerName -- Expected a string, but received: undefined.

Werteprüfung mit refinements

Neben einer Prüfung auf Vorhandensein und dem richtigen Typ von Feldern unterstützt superstruct auch das Prüfen der Werte von Feldern. In unserem Beispiel bietet es sich an, den title als nicht leer und das yearOfRelease als ganzzahlige Zahl zwischen 1900 und 2100 zu beschränken. Zusätzlich sollte das yearOfRelease Feld eine ganze Zahl sein. Um Werte einzuschränken, werden dem Schema refinements hinzugefügt.

				
					const refinedMovieSchema = object({
  title: nonempty(string()),
  yearOfRelease: max(min(integer(), 1900), 2100),
});

// Valides Objekt
const movieObject1: any = {
  title: "The Lord of the Rings: The Fellowship of the Ring",
  yearOfRelease: 1999.0,
};
is(movieObject1, refinedMovieSchema); // => true

// error => title ist ein leerer String
const movieObject2: any = {
  title: "",
  yearOfRelease: 2002,
};
is(movieObject2, refinedMovieSchema);  // => false

// error => yearOfRelease ist keine ganze Zahl
const movieObject3: any = {
  title: "The Lord of the Rings: The Two Towers",
  yearOfRelease: 2002.5,
};
is(movieObject3, refinedMovieSchema);  // => false

// error => yearOfRelease ist kleiner als 1900
const movieObject4: any = {
  title: "The Lord of the Rings: The Return of the King",
  yearOfRelease: 1899,
};
is(movieObject4, refinedMovieSchema);  // => false

// error => yearOfRelease größer als 2100
const movieObject5: any = {
  title: "The Hobbit: An Unexpected Journey",
  yearOfRelease: Infinity,
};
is(movieObject5, refinedMovieSchema);  // => false
				
			

Werte transformieren mit coercions

Ein weiteres Feature sind coercions. Dabei handelt es sich um Transformationen, die auf ein Feld vor dem Parsen und Validieren mit create oder validate angewendet werden. Ein praxisnahes Beispiel sind Datumsfelder. In APIs werden solche Felder oft als formatierte Strings versendet. Mit coercions kann man direkt prüfen, ob ein String ein valides Datum darstellt und dann aus dem String ein Datumsobjekt bauen:

				
					const coercionMovieSchema = object({
  title: string(),
  releaseDate: coerce(date(), string(), (value) => new Date(value)),
});

// Valides Objekt. Kein Fehler geworfen
const movieObject = {
  title: "Pulp Fiction",
  releaseDate: "1994-05-21T19:00:00.000Z",
};
const movie = create(movieObject, coercionMovieSchema);

// Fehler:
// err = 'StructError: At path: date -- Expected a valid `Date` object, but received: Invalid Date'
const movieObject2 = {
  title: "Pulp Fiction",
  releaseDate: "1994-04-31T19:00:00.000Z",
};
const [err, movie2] = validate(movieObject2, coercionMovieSchema);
				
			

Weitere Bibliotheken und Fazit

Neben den oben genannten gibt es noch einige weitere Featues von superstruct. Viele werden auch durch alternative Bibliotheken wie yup, ow, zod, ajv unterstützt.

Wir fanden für unseren Anwendungsfall superstruct am geeignetesten, da es sich neben den vorgestellten Features in der Praxis als äußerst performant (Validierungen pro Minute) bewiesen hat.