Runtime Type Guards generieren aus Validierungsschemas
AI generated
<T>
type
TypeScript · Type Guards · Zod · Narrowing
Runtime Type Guards generieren
Von der Validierung zur typsicheren Prüf-Funktion

Handgeschriebene Type Guards mit manuellen typeof- und in-Prüfungen driften bei jeder Änderung am zugrunde liegenden Typ leicht auseinander und werden in größeren Codebasen zur Fehlerquelle. Dieser Artikel zeigt, wie sich Runtime Type Guards automatisch aus Zod-Schemas ableiten lassen, mit einer generischen Guard-Factory, Discriminated-Union-Guards und einer klaren Trennung zwischen Type Predicate und Assertion Function.

12 Min. Lesezeit Type Predicates · asserts · safeParse Zod 3.x · TypeScript 5.x

1. Das Problem mit handgeschriebenen Type Guards

Ein klassischer Type Guard in TypeScript prüft zur Laufzeit, ob ein Wert einer bestimmten Struktur entspricht, meist mit einer Kette aus typeof-, in- und Array.isArray()-Aufrufen. Für ein einfaches Objekt mit zwei Feldern ist das schnell geschrieben, für ein Objekt mit verschachtelten Strukturen, optionalen Feldern und Arrays wächst diese Prüfung jedoch schnell zu einer unübersichtlichen Funktion, die bei jeder Typänderung händisch nachgepflegt werden muss.

Das eigentliche Risiko liegt nicht in der Länge der Funktion, sondern in der fehlenden Kopplung zwischen Typ und Guard. Ändert sich ein interface um ein neues Pflichtfeld, prüft der handgeschriebene Type Guard dieses Feld möglicherweise nicht, weil niemand daran gedacht hat, ihn anzupassen. Der Compiler warnt in diesem Fall nicht, weil eine Type-Predicate-Funktion für TypeScript aus Vertrauen akzeptiert wird, ihre tatsächliche Prüf-Logik aber nicht gegen den behaupteten Typ verifiziert wird.

2. Type Predicates: Die Grundlage jedes Type Guards

Ein Type Predicate ist eine Funktion, deren Rückgabetyp die Form value is X hat, statt eines einfachen boolean. Diese besondere Signatur teilt dem TypeScript-Compiler mit, dass ein true-Rückgabewert den Typ des geprüften Parameters im aufrufenden Code auf X einengt, ein Effekt, den ein gewöhnlicher boolean-Rückgabewert nicht auslöst.

Diese Einengung, das sogenannte Narrowing, funktioniert nur innerhalb desselben Kontrollflusses, etwa direkt nach einem if, das den Type Guard aufruft. Wichtig ist: Der Compiler vertraut der Signatur des Type Predicates vollständig, er prüft nicht, ob die tatsächliche Implementierung wirklich das behauptete Verhalten zeigt. Genau diese Vertrauensbeziehung ist der Grund, warum eine automatisch aus einem Schema generierte Guard-Funktion strukturell sicherer ist als eine von Hand geschriebene.


// A hand-written type guard: signature promises narrowing, body must deliver it
interface Product {
  id: string;
  name: string;
  price: number;
}

function isProduct(value: unknown): value is Product {
  return (
    typeof value === "object" &&
    value !== null &&
    "id" in value &&
    "name" in value &&
    "price" in value &&
    typeof (value as Product).id === "string" &&
    typeof (value as Product).name === "string" &&
    typeof (value as Product).price === "number"
  );
}

function printProductName(value: unknown): void {
  if (isProduct(value)) {
    // value is narrowed to Product here, based on the predicate signature
    console.log(value.name);
  }
}

3. Einen Type Guard direkt aus einem Zod-Schema ableiten

Statt die Prüf-Logik eines Type Guards von Hand nachzubilden, lässt sich ein Zod-Schema direkt in einen Type Guard verwandeln, indem .safeParse() im Rumpf einer Funktion mit der passenden value is X-Signatur aufgerufen wird. Der TypeScript-Typ X stammt dabei aus z.infer<typeof schema>, wodurch Schema, Typ und Guard aus derselben Quelle abgeleitet werden und niemals unabhängig voneinander gepflegt werden müssen.

Der praktische Vorteil gegenüber dem handgeschriebenen Guard aus dem vorherigen Abschnitt: Ändert sich das Schema, etwa durch ein zusätzliches Pflichtfeld, ändert sich automatisch auch das Verhalten der generierten is-Funktion, ohne dass eine einzige Zeile im Guard selbst angepasst werden muss. Das Schema bleibt die einzige Stelle im Code, die tatsächlich gepflegt wird.


import { z } from "zod";

const productSchema = z.object({
  id: z.string().uuid(),
  name: z.string().min(1),
  price: z.number().positive(),
});

type Product = z.infer<typeof productSchema>;

// Type guard derived directly from the schema, no manual field checks
function isProduct(value: unknown): value is Product {
  return productSchema.safeParse(value).success;
}

function printProductName(value: unknown): void {
  if (isProduct(value)) {
    // Narrowed to Product, guaranteed to match productSchema exactly
    console.log(value.name);
  }
}

4. Eine generische Guard-Factory für beliebige Schemas

Statt für jedes Schema eine eigene is-Funktion von Hand zu schreiben, lässt sich eine generische Fabrikfunktion definieren, die ein beliebiges Zod-Schema entgegennimmt und einen passenden Type Guard zurückgibt. Diese Guard-Factory arbeitet mit einem generischen Typparameter, der an das übergebene Schema gebunden ist, sodass TypeScript den korrekten, spezifischen Rückgabetyp für jeden Aufruf automatisch ableitet.

Dieses Muster reduziert Boilerplate erheblich, insbesondere in Codebasen mit Dutzenden Domänenobjekten. Ein neuer Typ benötigt keinen eigenen, manuell geschriebenen Guard mehr, sondern lediglich einen Aufruf der Factory mit dem passenden Schema, ein Einzeiler, der garantiert konsistent mit der Validierungslogik bleibt.


import { z, type ZodType } from "zod";

// Generic factory: turns any Zod schema into a matching type guard
function createTypeGuard<T>(schema: ZodType<T>) {
  return (value: unknown): value is T => schema.safeParse(value).success;
}

const orderSchema = z.object({
  orderId: z.string().uuid(),
  total: z.number().positive(),
});

const userSchema = z.object({
  userId: z.string().uuid(),
  email: z.string().email(),
});

// One line per type, always in sync with the schema
const isOrder = createTypeGuard(orderSchema);
const isUser = createTypeGuard(userSchema);

function handleUnknownPayload(payload: unknown): void {
  if (isOrder(payload)) {
    console.log(`Order ${payload.orderId}, total ${payload.total}`);
  } else if (isUser(payload)) {
    console.log(`User ${payload.userId}, ${payload.email}`);
  }
}

5. Type Guards für Discriminated Unions generieren

Für Discriminated Unions lässt sich dasselbe Prinzip nutzen, allerdings lohnt sich hier oft ein zweiter, spezifischerer Guard pro Variante, statt nur ein einziger Guard für die gesamte Union. Ein Zod-discriminatedUnion-Schema erlaubt über .options den Zugriff auf die einzelnen Teilschemas, aus denen sich pro Variante ein eigener, benannter Type Guard generieren lässt, etwa isCardCharged oder isRefundIssued.

Diese pro Variante generierten Guards sind besonders nützlich in Code, der nicht über ein zentrales switch-Statement verzweigt, sondern einzelne Ereignistypen an verschiedenen Stellen der Anwendung separat behandelt, etwa in unabhängigen Event-Handlern, die jeweils nur an einer bestimmten Variante interessiert sind.


import { z, type ZodType } from "zod";

const paymentEventSchema = z.discriminatedUnion("type", [
  z.object({ type: z.literal("card_charged"), amount: z.number() }),
  z.object({ type: z.literal("refund_issued"), amount: z.number() }),
]);

function createTypeGuard<T>(schema: ZodType<T>) {
  return (value: unknown): value is T => schema.safeParse(value).success;
}

// Generate one focused guard per union variant from its sub-schema
const [cardChargedSchema, refundIssuedSchema] = paymentEventSchema.options;
const isCardCharged = createTypeGuard(cardChargedSchema);
const isRefundIssued = createTypeGuard(refundIssuedSchema);

function handleEvent(event: unknown): void {
  if (isCardCharged(event)) {
    console.log(`Charged: ${event.amount}`);
    return;
  }
  if (isRefundIssued(event)) {
    console.log(`Refunded: ${event.amount}`);
  }
}

6. Assertion Functions statt Type Predicates einsetzen

Neben Type Predicates unterstützt TypeScript auch Assertion Functions mit der Signatur asserts value is X. Der Unterschied: Ein Type Predicate gibt einen boolean zurück, der in einer Bedingung ausgewertet wird, während eine Assertion Function entweder normal zurückkehrt oder eine Exception wirft, und der Typ danach im gesamten restlichen Code-Pfad als eingeengt gilt, ohne ein umschließendes if.

Für generierte Guards aus Zod-Schemas lässt sich dieselbe Factory leicht um eine Assertion-Variante ergänzen, die intern .parse() statt .safeParse() nutzt und bei ungültigen Daten wirft. Diese Variante eignet sich besonders für Stellen im Code, an denen ungültige Daten einen echten Programmfehler darstellen und ein früher Absturz mit klarer Fehlermeldung wichtiger ist als eine stille, kontrollierte Ablehnung.


import { z, type ZodType } from "zod";

// Assertion function factory: throws instead of returning a boolean
function createAssertion<T>(schema: ZodType<T>) {
  return (value: unknown): asserts value is T => {
    const result = schema.safeParse(value);
    if (!result.success) {
      throw new Error(`Assertion failed: ${result.error.message}`);
    }
  };
}

const configSchema = z.object({
  apiUrl: z.string().url(),
  timeoutMs: z.number().positive(),
});

const assertValidConfig = createAssertion(configSchema);

function loadConfig(raw: unknown) {
  // No "if" needed: after this call, raw is narrowed for the rest of the function
  assertValidConfig(raw);
  return raw; // typed as the inferred config shape, not unknown
}

7. Arrays und Listen mit generierten Guards filtern

Ein häufiger Anwendungsfall für generierte Type Guards ist das Filtern von Arrays mit gemischtem oder unsicherem Inhalt, etwa das Ergebnis von JSON.parse() auf einer Liste unbekannter Objekte. Array.prototype.filter() in Kombination mit einem Type Predicate engt den resultierenden Array-Typ automatisch ein, TypeScript erkennt, dass nach dem Filter nur noch Elemente des geprüften Typs übrig bleiben, ganz ohne zusätzlichen Type-Cast.

Dieses Muster ist besonders wertvoll beim Verarbeiten von API-Antworten mit potenziell inkonsistenten Einträgen, etwa wenn ein Feed teilweise fehlerhafte Datensätze enthält. Statt die gesamte Antwort bei einem einzigen ungültigen Eintrag zu verwerfen, filtert der generierte Guard defekte Einträge gezielt heraus und lässt valide Datensätze unverändert typsicher weiterverarbeiten.


import { z, type ZodType } from "zod";

function createTypeGuard<T>(schema: ZodType<T>) {
  return (value: unknown): value is T => schema.safeParse(value).success;
}

const reviewSchema = z.object({
  rating: z.number().min(1).max(5),
  comment: z.string(),
});

const isReview = createTypeGuard(reviewSchema);

// Filter narrows the array type automatically, no cast needed afterwards
const rawEntries: unknown[] = JSON.parse(externalFeed);
const validReviews = rawEntries.filter(isReview);
const averageRating =
  validReviews.reduce((sum, r) => sum + r.rating, 0) / validReviews.length;

8. Grenzen generierter Type Guards und wann manuell schreiben

Generierte Type Guards decken den überwiegenden Teil der Fälle ab, stoßen aber an Grenzen, wenn eine Prüfung mehr Kontext braucht als das Schema selbst liefert, etwa eine Cross-Objekt-Regel, die einen Wert gegen den aktuellen Anwendungszustand statt nur gegen seine eigene Struktur prüft. In diesen Fällen bleibt ein manuell geschriebener Guard, der zusätzliche Parameter entgegennimmt, die pragmatischere Lösung.

Ein weiterer Grenzfall ist Performance in Hot Paths mit sehr hoher Aufruffrequenz, etwa in einer Render-Schleife. safeParse() baut bei jedem Aufruf ein vollständiges Ergebnisobjekt inklusive potenzieller Fehlerdetails auf, was messbar teurer ist als eine schlanke, handgeschriebene typeof-Prüfung. Für solche Stellen lohnt sich ein bewusster Kompromiss zwischen Wartbarkeit und Rohperformance, statt das generierte Muster unreflektiert überall einzusetzen.

9. Manuelle vs. generierte Type Guards im Vergleich

Die folgende Übersicht stellt beide Ansätze entlang der Kriterien gegenüber, die in der Praxis am häufigsten den Ausschlag geben.

Kriterium Manueller Type Guard Generierter Type Guard
Synchronität mit dem Typ Muss bei Typänderung manuell nachgezogen werden Automatisch synchron, da aus demselben Schema
Boilerplate pro Typ Eigene Funktion mit vollständiger Prüf-Logik Ein Aufruf der Guard-Factory
Performance pro Aufruf Sehr schnell, minimale Prüfung Etwas langsamer durch vollständiges Ergebnisobjekt
Cross-Objekt-Regeln Beliebig erweiterbar mit Zusatzparametern Nur strukturelle Prüfung ohne externen Kontext
Fehlerquelle bei Vergessen Stiller Bug, Compiler warnt nicht Ausgeschlossen, da Guard direkt vom Schema abhängt

Für die überwiegende Mehrheit der Domänenobjekte in einer Anwendung ist der generierte Ansatz die robustere Wahl, während einzelne, performancekritische oder kontextabhängige Guards weiterhin gezielt von Hand geschrieben werden sollten.

Mironsoft

Type Guards, Guard-Factories und Runtime Validation für eure TypeScript-Codebasis

Type Guards nicht mehr von Hand pflegen?

Wir bauen eine generische Guard-Factory für eure Domänenobjekte, generieren Discriminated-Union-Guards und richten Assertion Functions an den kritischen Stellen im Code ein.

Guard-Audit

Bestehende handgeschriebene Type Guards auf Drift prüfen

Guard-Factory

Generische Ableitung aus bestehenden Zod-Schemas einführen

Performance-Tuning

Hot Paths identifizieren und gezielt manuell optimieren

10. Zusammenfassung

Runtime Type Guards sind ein Bereich, in dem manuelle Pflege besonders leicht zu stillen Bugs führt, weil der Compiler der Signatur eines Type Predicates vertraut, ohne die tatsächliche Implementierung gegen den behaupteten Typ zu prüfen. Das Generieren von Type Guards direkt aus Zod-Schemas schließt diese Lücke strukturell, da Schema, Typ und Guard aus derselben Quelle abgeleitet werden und niemals unabhängig voneinander auseinanderdriften können.

Eine generische Guard-Factory reduziert Boilerplate über Dutzende Domänenobjekte auf einen einzigen Funktionsaufruf pro Typ, während Discriminated-Union-Guards und Assertion Functions gezielt für Sonderfälle wie Event-Handling oder frühzeitige Programmabbrüche eingesetzt werden können. Nur in Hot Paths mit extrem hoher Aufruffrequenz oder bei Cross-Objekt-Regeln bleibt ein handgeschriebener Guard die pragmatischere Lösung.

Runtime Type Guards generieren, das Wichtigste auf einen Blick

Type Predicates verstehen

value is X engt den Typ ein, der Compiler vertraut der Signatur ungeprüft.

Guard-Factory

Ein generischer Wrapper um safeParse() deckt beliebige Zod-Schemas ab.

Discriminated Unions und Arrays

Guards pro Variante generieren, filter() engt Array-Typen automatisch ein.

Grenzen kennen

Hot Paths und Cross-Objekt-Regeln rechtfertigen weiterhin manuelle Guards.

11. FAQ: Runtime Type Guards generieren

1Unterschied zwischen Type Guard und Type Predicate?
Type Guard ist die Funktion, Type Predicate die spezielle Rückgabetyp-Signatur value is X, die dem Compiler das Narrowing signalisiert.
2Warum sind handgeschriebene Guards fehleranfällig?
Der Compiler vertraut der Signatur ungeprüft. Ändert sich der Typ, aber nicht der Guard, entsteht ein stiller Bug ohne Compiler-Warnung.
3Wie generiert man einen Guard aus einem Zod-Schema?
Eine Funktion mit value is T ruft schema.safeParse(value).success auf, T stammt aus z.infer. Schema, Typ und Guard aus einer Quelle.
4Was macht eine generische Guard-Factory?
Nimmt ein beliebiges Schema entgegen und gibt eine passende is-Funktion zurück, ein Aufruf statt einer komplett neuen Guard-Funktion.
5Guards für Discriminated-Union-Varianten generieren?
Über das options-Array die Teilschemas extrahieren und jeweils mit derselben Factory in einen eigenen, benannten Guard umwandeln.
6Unterschied Type Predicate zu Assertion Function?
Predicate gibt boolean zurück, Assertion Function mit asserts value is X wirft bei ungültigen Daten, Typ gilt danach ohne if als eingeengt.
7Generierte Guards zum Filtern von Arrays nutzen?
filter() erkennt ein Type Predicate automatisch und engt den Array-Typ entsprechend ein, ungültige Einträge werden typsicher entfernt.
8Wann trotz Factory manuell schreiben?
Bei Cross-Objekt-Regeln mit externem Kontext und in Hot Paths mit sehr hoher Aufruffrequenz, wo safeParse messbar ins Gewicht fällt.
9Sind generierte Guards langsamer?
Geringfügig, da safeParse ein vollständiges Ergebnisobjekt aufbaut. Für die meisten Fälle vernachlässigbar, in Hot Paths messbar.
10Funktioniert das auch mit Valibot?
Ja, bibliotheksunabhängig. Die Factory muss lediglich safeParse durch v.safeParse ersetzen und die Ergebnisfelder entsprechend auswerten.