In diesem Blogartikel schauen wir uns die TypeScript-Bibliothek „zod“ an. Dabei gehe ich genauer auf die Einschränkungen von TypeScripts Typen-System zur Laufzeit ein und  zeige euch, wie zod diese Probleme löst.

Das Problem mit TypeScript

TypeScript baut ein Typen-System um die Web-Programmiersprache JavaScript auf. Mit diesem Typen-System kann ein konkretes Interface für Objekte und Funktionen definiert werden. Wenn ich zum Beispiel versuche, einer Funktion, die eine Zahl erwartet, einen String mitzugeben, dann wirft TypeScripts Compiler tsc beim Konvertieren einen Fehler – vorausgesetzt TypeScript ist sich sicher , dass dort ein String übergeben wurde.

Wichtig ist dabei, dass diese Überprüfungen nur zur Compile-Zeit passieren, also während der Entwicklung. Nachdem tsc die TypeScript-Datei nach JavaScript konvertiert hat, gehen alle Typen-Informationen verloren. JavaScript hat kein Konzept von Typen. Dementsprechend wird auch das Program zur Laufzeit, anders als bei Sprachen wie Java oder C#, keinen Fehler auswerfen, wenn beispielsweise statt einer Zahl ein String übergeben wird.

Solange wir unsere TypeScript-Anwendung nicht mit anys bestücken, sollte das bei statischen Daten keine Probleme mit sich bringen; wenn in eine Variable beispielsweise eine Zahl geschrieben wird, dürfen wir davon ausgehen, dass die Variable zur Laufzeit auch eine Zahl beinhaltet.

Bei dynamischen Daten sieht das allerdings anders aus. Also bei allem, dessen Struktur nicht zur Compile-Zeit garantiert ist. Erwarten wir beispielsweise von einer API eine JSON mit einer bestimmten Struktur, hilft uns das Typen-System von TypeScript bei der Validierung nicht weiter. Denn, wie schon oben erwähnt, ist das Typen-System nur während der Entwicklung verfügbar.

Hier ein kleines Beispiel eines Aufrufs mit fetch.

interface Body {
  name: string;
  id: number;
}
const result: Body = await fetch('...').then((e) => e.json());
//            ^^^^- hier wird der Body explizit definiert
// ob hier wirklich ein Body zurückgegeben wird wissen
// wir erst zur Laufzeit und müssen es auch zur Laufzeit
// überprüfen
console.log(result.name);
//                 ^^^^ Dadurch, dass oben result als Body gesetzt ist
// geht TS davon aus, dass Result ein Body ist. Würde hier etwas anderes
// als id oder name stehen, würde es zu einem Compile-Fehler kommen.

Nachdem tsc diese Eingabe nach JavaScript konvertiert, gehen die Typen-Informationen verloren:

const result = await fetch('...').then((e) => e.json());
console.log(result.name);

Ob fetch hier ein JSON-Objekt mit der korrekten Struktur zurückgibt erfahren wir nur, indem wir zur Laufzeit den Inhalt überprüfen, beispielsweise über eine Funktion wie:

function isBody(body: unknown): body is Body {
  if (typeof body !== 'object' || body === null) {
    return false;
  }
  if ('name' in body) {
    if (typeof body.name !== 'string') {
      return false;
    }
  } else {
    return false;
  }
  if ('id' in body) {
    if (typeof body.id !== 'number') {
      return false;
    }
  } else {
    return false;
  }
  return true;
}

Wie Zod helfen kann

Statt solche Validierungs-Funktionen „von Hand“ zu schreiben, kann Zod verwendet werden. Zod ist eine Bibliothek, mit der wir mittels TypeScript-Objekten ein Schema definieren können. Gegen dieses Schema können dann Objekte validiert werden. Die oben gezeigte Funktion lässt sich beispielsweise mit Zod wie gefolgt ausdrücken:

import { z } from 'zod';
export const BodyZod = z.object({
  name: z.string(),
  id: z.number(),
});

Das erstellte Schema kann dann importiert und gegen ein unbekanntes Objekt angewandt werden:

const responseUnchecked: unknown = await fetch('...').then((e) => e.json());
try {
  const response = BodyZod.parse(responseUnchecked);
  console.log('validated', response);
} catch (err) {
  console.error('failed validation', err);
}

Bei der Validierung mit der .parse()-Methode gibt Zod automatisch aus dem Schema abgeleitete Typen ab. Wir erhalten mit Zod somit eine umfassende Typen-Überprüfung: Sowohl zur Laufzeit als auch zur Compile-Zeit!

Beispielsweise wirft tsc  für folgenden Code-Block einen Fehler aus:

const response = BodyZod.parse(responseUnchecked);
response.test = 42; // ! Fehler
// Property 'test' does not exist on type '{ name: string; id: number; }'

Wir können die Typen-Definitionen auch direkt aus dem Schema extrahieren:

type Body = z.infer<typeof BodyZod>;
// identisch zu
type Body2 = {
  name: string;
  id: number;
};

Neben den Typen können auch weitere Beschränkungen definiert werden:
So kann ein Validator, der ein Enum oder eine ganze, positive Zahl, die nicht größer als 42 ist, wie folgt definiert werden:

const ExampleZod = z
  .enum(['value1', 'value2'])
  .or(z.number().int().positive().max(42));
type Example = z.infer; // = number | "value1" | "value2"

Ein weiteres Beispiel wäre ein Validator mit zwei Strings, die eine E-Mail und optional einen Regex-konformen String definieren:

const Example2Zod = z.object({
  email: z.string().email(),
  alphaNumeric: z
    .string()
    .regex(/[a-zA-Z0-9]*/)
    .optional(),
});

Eine Auflistung aller Typen und Beschränkungen findest du auf dieser Dokuseite.

Fazit

Zod bietet eine intuitive Möglichkeit, einen Zustand, den wir zur Compile-Zeit erwarten und der zur Laufzeit tatsächlich aufgetreten ist, zu synchronisieren.

Dies ist wichtig für Anwendungen, die Nutzereingaben oder externe Quellen, beispielsweise über fetch oder Angulars HttpClient, verwenden. Denn hier hilft uns das Typen-System von TypeScript nur während der Entwicklung weiter, während es zur Laufzeit keine Typenüberprüfung gibt.

Zusätzlich können mit Zod auch Beschränkungen definiert werden, die über TypeScripts Typensystem hinausgehen.

Dran bleiben!

Im zweiten Teil dieser kleinen Blog-Serie möchte ich dir Zod an einem konkreten Beispiel  zeigen. Dafür sehen wir uns die Validierungsschnittstelle zwischen einem Quarkus-Backend und einem Angular-Frontend genauer an. Schau also gerne wieder rein …

Profilbild Waldemar Lehner
Alle Beiträge von Waldemar Lehner

Associate Developer mit Fokus auf Web-Technologien

Schreibe einen Kommentar