Formulardaten typsicher validieren mit Zod und Co.
<T>
type
TypeScript · Zod · Valibot · Formularvalidierung
Formulardaten typsicher validieren mit Zod und Co.
Wie Schema-Validierung Laufzeit-Sicherheit und TypeScript-Typen vereint

TypeScript-Typen existieren nur zur Kompilierzeit und schützen Formulardaten nicht vor unsauberen Nutzereingaben, die zur Laufzeit als unbekannte Strings ankommen. Dieser Artikel zeigt, wie Schema-Validierungsbibliotheken wie Zod und Valibot Laufzeit-Prüfung und TypeScript-Typen aus einer einzigen Quelle ableiten, praxisnah für Checkout-Formulare, native FormData-Verarbeitung, typisierte API-Schichten und verständliche Fehlermeldungen in der Benutzeroberfläche.

12 Min. Lesezeit Zod · Valibot · z.infer Formulare · FormData · API-Validierung

1. Warum TypeScript-Typen zur Laufzeit nichts wert sind

TypeScript-Typen existieren ausschließlich zur Kompilierzeit. Der Compiler prüft Zuweisungen, Funktionsaufrufe und Interfaces gegen ein statisches Typsystem, entfernt diese Information beim Build aber vollständig aus dem erzeugten JavaScript, ein Vorgang, der als Type Erasure bekannt ist. Zur Laufzeit existiert kein interface, kein type-Alias und keine Generic-Signatur mehr, nur reines JavaScript ohne jede Typprüfung. Wer glaubt, ein interface ContactForm würde zur Laufzeit irgendetwas verhindern, verwechselt eine reine Entwicklerhilfe mit einer echten Absicherung.

Formulardaten sind ein besonders kritischer Fall, weil sie strukturell immer als unknown oder string in die Anwendung gelangen, egal ob aus einem <input>-Element, einem FormData-Objekt, einem JSON.parse()-Aufruf oder einer externen API. Ein Typ-Cast mit as ContactForm ändert daran nichts, er ist lediglich eine Behauptung gegenüber dem Compiler, keine Prüfung. Erst eine echte Laufzeit-Validierung entscheidet, ob die Behauptung zutrifft, bevor fehlerhafte Daten in Geschäftslogik, Datenbank oder API-Aufrufe weiterfließen.

2. Schema-first: Typen aus der Laufzeit-Validierung ableiten

Schema-Validierungsbibliotheken wie Zod und Valibot lösen das Problem, indem sie das Verhältnis zwischen Typ und Validierung umdrehen. Statt zuerst ein TypeScript-Interface zu schreiben und die Laufzeit-Prüfung separat und manuell nachzuziehen, definiert man ein Schema als einzige Quelle der Wahrheit, aus dem der TypeScript-Typ automatisch abgeleitet wird. Bei Zod übernimmt z.infer<typeof schema> diese Ableitung, bei Valibot v.InferOutput<typeof schema>. Beide Muster verhindern strukturell, dass Typ und Validierungslogik auseinanderdriften.

Der praktische Gewinn liegt in der Wartbarkeit: Ändert sich ein Feld im Schema, etwa weil ein Pflichtfeld optional wird, aktualisiert sich der abgeleitete Typ automatisch mit, und der Compiler markiert jede Stelle im Code, die mit der alten Annahme nicht mehr kompatibel ist. Ein von Hand gepflegtes interface neben einer separaten Validierungsfunktion bietet diese Garantie nicht, dort können Typ und Prüfung beliebig lange unbemerkt auseinanderlaufen, bis ein Laufzeitfehler die Diskrepanz aufdeckt.

3. Ein Zod-Schema für ein Checkout-Formular in der Praxis

Ein realistisches Beispiel ist ein Checkout-Formular mit verschachtelten Adressobjekten und einer bedingten Regel: Die Rechnungsadresse ist nur dann erforderlich, wenn sie von der Lieferadresse abweicht. Zod bildet verschachtelte Strukturen über z.object() innerhalb von z.object() ab und erlaubt mit .refine() zusätzliche Validierungslogik, die über einfache Feldregeln hinausgeht, inklusive eines path, damit der Fehler dem richtigen Feld zugeordnet wird.

Wichtig ist der Unterschied zwischen .parse(), das bei ungültigen Daten eine Exception wirft, und .safeParse(), das stattdessen ein Ergebnisobjekt mit success-Flag zurückgibt. Für Formulare in einer UI ist safeParse fast immer die richtige Wahl, weil Validierungsfehler ein normaler, erwarteter Zustand sind und nicht mit try/catch behandelt werden sollten.


import { z } from "zod";

// Nested address schema reused inside the checkout schema
const addressSchema = z.object({
  street: z.string().min(3, "Strasse ist zu kurz"),
  postalCode: z.string().regex(/^\d{5}$/, "PLZ muss 5 Ziffern haben"),
  city: z.string().min(2),
  country: z.enum(["DE", "AT", "CH"]),
});

export const checkoutFormSchema = z
  .object({
    email: z.string().email("Ungueltige E-Mail-Adresse"),
    firstName: z.string().min(1, "Vorname erforderlich"),
    lastName: z.string().min(1, "Nachname erforderlich"),
    shippingAddress: addressSchema,
    billingAddress: addressSchema.optional(),
    useSameAddress: z.boolean().default(true),
    newsletter: z.boolean().default(false),
  })
  // Refinement: billingAddress is required when useSameAddress is false
  .refine(
    (data) => data.useSameAddress || data.billingAddress !== undefined,
    {
      message: "Rechnungsadresse ist erforderlich, wenn sie abweicht",
      path: ["billingAddress"],
    }
  );

// The TypeScript type is derived from the schema, never written by hand
export type CheckoutFormData = z.infer<typeof checkoutFormSchema>;

// Runtime parsing: safeParse returns a result object instead of throwing
const result = checkoutFormSchema.safeParse(rawFormInput);
if (!result.success) {
  console.error(result.error.flatten().fieldErrors);
}

4. Valibot als leichtgewichtige Alternative zu Zod

Valibot verfolgt dasselbe Schema-first-Prinzip wie Zod, ist aber von Grund auf für Tree-Shaking konzipiert. Statt einer monolithischen z-Objekt-API mit gekoppelten Methoden importiert Valibot einzelne, unabhängige Funktionen wie minLength() oder email() aus einem funktionalen pipe()-System. Bundler entfernen dadurch konsequent jeden Validator, der im Projekt nicht tatsächlich verwendet wird, während Zods Kernklasse als zusammenhängender Block im Bundle landet, unabhängig davon, wie viele der enthaltenen Methoden man nutzt.

In Zahlen bedeutet das für ein typisches Formular-Schema oft einen Unterschied zwischen wenigen Kilobyte bei Valibot und einem deutlich größeren, kaum weiter reduzierbaren Anteil bei Zod. Für Admin-Backends oder interne Tools, bei denen Bundle-Größe zweitrangig ist, bleibt Zod wegen der reiferen Ökosystem-Integration und der ausführlicheren Fehlermeldungen oft die pragmatischere Wahl. Für öffentliche, performance-kritische Storefronts, insbesondere in einem Hyvä-Kontext mit minimalem JavaScript-Anspruch, ist Valibot die konsequentere Entscheidung.


import * as v from "valibot";

// Same nested address shape, expressed with Valibot's functional pipe API
const AddressSchema = v.object({
  street: v.pipe(v.string(), v.minLength(3, "Strasse ist zu kurz")),
  postalCode: v.pipe(v.string(), v.regex(/^\d{5}$/, "PLZ muss 5 Ziffern haben")),
  city: v.pipe(v.string(), v.minLength(2)),
  country: v.picklist(["DE", "AT", "CH"]),
});

export const CheckoutFormSchema = v.object({
  email: v.pipe(v.string(), v.email("Ungueltige E-Mail-Adresse")),
  firstName: v.pipe(v.string(), v.minLength(1, "Vorname erforderlich")),
  lastName: v.pipe(v.string(), v.minLength(1, "Nachname erforderlich")),
  shippingAddress: AddressSchema,
  billingAddress: v.optional(AddressSchema),
  useSameAddress: v.boolean(),
  newsletter: v.optional(v.boolean(), false),
});

// Type inference works the same way as with Zod's z.infer
export type CheckoutFormData = v.InferOutput<typeof CheckoutFormSchema>;

// Only the validator functions actually imported end up in the bundle
const result = v.safeParse(CheckoutFormSchema, rawFormInput);

5. Validierung mit nativen HTML-Formularen und FormData verbinden

Native HTML-Formulare liefern ihre Daten über die FormData-API als Sammlung von String- und File-Einträgen, unabhängig davon, welchen Datentyp ein Feld eigentlich repräsentiert. Checkboxen liefern beim Absenden entweder den String "on" oder gar keinen Eintrag, niemals einen Boolean. Diese Rohform muss vor der Übergabe an ein Schema in ein einfaches Objekt umgewandelt werden, wobei genau an dieser Stelle Coercion-Regeln für Checkboxen, Zahlen und Datumswerte explizit angewendet werden sollten, statt sie stillschweigend dem Schema zu überlassen.

Der Vorteil dieses Ansatzes gegenüber einer kontrollierten React- oder Alpine.js-Formularbindung mit useState pro Feld: Das native Formular bleibt die einzige Quelle für den aktuellen Zustand, es gibt keine Synchronisationslogik zwischen DOM und JavaScript-State, und die Validierung greift erst im Moment des Absendens auf einem vollständigen, konsistenten Snapshot der Eingaben. Für einfache Formulare ohne Live-Validierung pro Tastenanschlag ist das oft die robustere und wartungsärmere Lösung.


// Convert native FormData into a plain object before validating
function formDataToObject(formData: FormData): Record<string, unknown> {
  const entries: Record<string, unknown> = {};
  for (const [key, value] of formData.entries()) {
    entries[key] = value;
  }
  return entries;
}

async function handleCheckoutSubmit(event: SubmitEvent): Promise<void> {
  event.preventDefault();
  const form = event.target as HTMLFormElement;
  const formData = new FormData(form);

  // Checkbox fields arrive as "on" / missing, so coerce them explicitly
  const raw = {
    ...formDataToObject(formData),
    useSameAddress: formData.get("useSameAddress") === "on",
    newsletter: formData.get("newsletter") === "on",
  };

  const result = checkoutFormSchema.safeParse(raw);
  if (!result.success) {
    renderFieldErrors(result.error.flatten().fieldErrors);
    return;
  }

  // result.data is now fully typed as CheckoutFormData, not raw FormData
  await submitCheckout(result.data);
}

6. Typisierte API-Schicht: Requests und Responses validieren

Schema-Validierung endet nicht am Formular. Die gleiche Unsicherheit, die für Nutzereingaben gilt, gilt spiegelbildlich für API-Antworten: response.json() liefert immer den Typ any, eine reine Behauptung ohne jede Prüfung. Ein Backend-Deploy, ein API-Versionswechsel oder ein einfacher Bug auf Serverseite kann jederzeit eine Struktur zurückliefern, die vom erwarteten Typ abweicht, ohne dass TypeScript davon je etwas merkt.

Die konsequente Lösung ist, Response-Schemas genauso zu definieren wie Formular-Schemas und jede API-Antwort vor der Weiterverarbeitung durch .parse() zu schicken. Dieser eine zusätzliche Aufruf verwandelt unknown Netzwerkdaten in einen verlässlich typisierten Wert und deckt zuverlässig auf, wenn Frontend und Backend im Vertragsformat auseinanderlaufen, statt dass der Fehler erst tief in der Anwendung als kryptischer undefined is not a function-Fehler auftaucht.


import { z } from "zod";

// Schema for the API response, decoupled from the internal domain model
const orderConfirmationSchema = z.object({
  orderId: z.string().uuid(),
  status: z.enum(["pending", "confirmed", "failed"]),
  estimatedDelivery: z.string().datetime(),
  total: z.object({
    amount: z.number().positive(),
    currency: z.literal("EUR"),
  }),
});

type OrderConfirmation = z.infer<typeof orderConfirmationSchema>;

async function submitCheckout(
  data: CheckoutFormData
): Promise<OrderConfirmation> {
  const response = await fetch("/api/checkout", {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify(data),
  });

  if (!response.ok) {
    throw new Error(`Checkout failed with status ${response.status}`);
  }

  // Parse the untrusted JSON payload through the schema, not just a cast
  const json: unknown = await response.json();
  return orderConfirmationSchema.parse(json);
}

7. Fehlermeldungen und typisierte Feldfehler für die UI

Ein rohes ZodError-Objekt ist für die Verarbeitung in der UI unhandlich, weil Fehler standardmäßig als flache Liste mit Pfadangaben vorliegen. error.flatten().fieldErrors wandelt diese Liste in ein Objekt um, das pro Feldnamen ein Array von Fehlermeldungen enthält, genau die Struktur, die sich direkt an einzelne Formularfelder binden lässt. Bei verschachtelten Objekten wie der Adresse im Checkout-Beispiel empfiehlt sich zusätzlich error.format(), das die verschachtelte Struktur des Schemas beibehält, statt sie auf eine flache Ebene zu reduzieren.

Für wiederverwendbare UI-Komponenten lohnt sich ein generischer FieldErrors<T>-Typ, der die Schlüssel des Formular-Typs mit optionalen Fehlerarrays verknüpft. Eine FormField-Komponente kann diesen Typ dann als Prop erwarten und über aria-invalid sowie role="alert" nicht nur visuelle, sondern auch für Screenreader zugängliche Fehlermeldungen ausgeben, ohne dass die Komponente selbst irgendetwas über Zod oder Valibot wissen muss.


import type { z } from "zod";

// Flattened, field-keyed error shape suitable for direct UI binding
type FieldErrors<T extends Record<string, unknown>> = Partial<
  Record<keyof T, string[]>
>;

function mapZodErrors<T extends Record<string, unknown>>(
  error: z.ZodError<T>
): FieldErrors<T> {
  return error.flatten().fieldErrors as FieldErrors<T>;
}

function FormField({
  label,
  name,
  errors,
}: {
  label: string;
  name: string;
  errors: FieldErrors<CheckoutFormData>;
}) {
  const fieldErrors = errors[name as keyof CheckoutFormData];

  return (
    <div>
      <label htmlFor={name}>{label}</label>
      <input id={name} name={name} aria-invalid={Boolean(fieldErrors)} />
      {fieldErrors?.map((message) => (
        <p key={message} role="alert" className="text-red-600 text-sm">
          {message}
        </p>
      ))}
    </div>
  );
}

8. Eigene Validierungsregeln und Cross-Field-Validierung

Viele reale Formularregeln lassen sich nicht auf ein einzelnes Feld reduzieren. Das klassische Beispiel ist die Passwortbestätigung, bei der zwei Felder übereinstimmen müssen, ein anderes ist ein bedingtes Pflichtfeld, das nur unter bestimmten Umständen erforderlich ist, wie die Rechnungsadresse im Checkout-Beispiel. Zod deckt beide Fälle über .refine() auf dem gesamten Objekt ab, wobei die Callback-Funktion Zugriff auf alle Felder gleichzeitig hat, statt isoliert nur ein einzelnes Feld zu prüfen.

Für mehrere unabhängige Cross-Field-Regeln innerhalb desselben Schemas ist .superRefine() oft die bessere Wahl, weil es erlaubt, über einen ctx-Parameter mehrere addIssue()-Aufrufe mit jeweils eigenem path abzusetzen, statt mehrere .refine()-Aufrufe zu verketten, die sich gegenseitig in der Fehlerreihenfolge beeinflussen können. Wichtig ist, Cross-Field-Regeln bewusst von einfachen Feldregeln zu trennen, damit ein Schema lesbar bleibt und nicht zu einer undurchsichtigen Kette von Bedingungen wird, die niemand mehr vollständig überblickt.

9. Manuelle vs. schema-basierte Validierung im Vergleich

Der Unterschied zwischen manueller, untypisierter Validierung und schema-abgeleiteter, typsicherer Validierung zeigt sich am deutlichsten im direkten Vergleich der täglichen Entwicklungspraxis.

Aspekt Manuell / untypisiert Schema-basiert / typsicher
Typquelle Interface separat von Hand gepflegt Typ per z.infer aus dem Schema abgeleitet
Laufzeitprüfung If-Ketten, leicht vergessen oder inkonsistent Zentrales Schema, an einer Stelle definiert
Synchronität Typ und Prüfung driften unbemerkt auseinander Compiler erzwingt Konsistenz bei Änderungen
FormData-Handling Manuelle Casts wie "as ContactForm" safeParse liefert echten, geprüften Typ
API-Responses response.json() als any ungeprüft übernommen Response-Schema deckt Vertragsbrüche sofort auf
Fehlermeldungen Uneinheitliche, hart codierte Strings Strukturierte, feldbezogene Fehlerobjekte
Bundle-Kosten Keine zusätzliche Abhängigkeit Wenige KB, mit Valibot tree-shakebar minimal

In der Praxis zahlt sich schema-basierte Validierung besonders bei wachsenden Formularen und mehreren Teammitgliedern aus, weil der Compiler jede Inkonsistenz zwischen Typ und tatsächlicher Prüfung sofort sichtbar macht, statt sie erst zur Laufzeit beim Kunden auffliegen zu lassen.

Mironsoft

Typsichere Formulare, Schema-Validierung und API-Integration für Magento und Hyvä

Formulare und APIs typsicher validieren lassen?

Wir bauen Zod- oder Valibot-Schemas für eure Formulare und API-Schichten, verbinden sie mit nativen FormData-Flows und liefern typisierte Feldfehler direkt in der Benutzeroberfläche aus.

Schema-Audit

Bestehende Formulare und API-Verträge auf Typsicherheit prüfen

Zod / Valibot Setup

Schemas mit z.infer als einzige Quelle der Wahrheit einführen

API-Integration

Typisierte Requests und Responses in eurer Fetch-Schicht

10. Zusammenfassung

Formulardaten typsicher zu validieren löst ein strukturelles Problem von TypeScript: Typen verschwinden zur Laufzeit vollständig, während Formulardaten, FormData-Objekte und API-Responses immer als unknown oder string in die Anwendung gelangen. Schema-Validierungsbibliotheken wie Zod und Valibot lösen das, indem sie den TypeScript-Typ direkt aus dem Laufzeit-Schema ableiten, sodass Typ und Prüfung nie auseinanderdriften können. Ein Zod-Schema mit verschachtelten Objekten, Refinements und z.infer deckt komplexe Checkout-Formulare vollständig ab, während Valibots funktionale, tree-shakebare API für performance-kritische Storefronts oft die leichtere Wahl ist.

Der entscheidende Hebel liegt darin, Schema-Validierung nicht auf Formulare zu beschränken, sondern konsequent auch auf API-Responses anzuwenden, da response.json() ebenso ungeprüft any zurückgibt wie ein rohes Formularfeld einen String liefert. Strukturierte, feldbezogene Fehlerobjekte aus flatten().fieldErrors lassen sich direkt an UI-Komponenten binden, und Cross-Field-Regeln über .refine() oder .superRefine() decken auch Passwortbestätigungen und bedingte Pflichtfelder sauber ab, ohne die Übersicht über das Schema zu verlieren.

Formulardaten typsicher validieren, das Wichtigste auf einen Blick

Type Erasure verstehen

TypeScript-Typen verschwinden zur Laufzeit, Formulardaten sind immer unknown/string.

Schema als Single Source

z.infer/v.InferOutput leiten den Typ direkt aus dem Validierungsschema ab.

Zod vs. Valibot

Zod für Ökosystem und DX, Valibot für minimale, tree-shakebare Bundles.

FormData & API-Layer

Gleiches Schema für native Formulare, FormData und typisierte fetch-Responses nutzen.

11. FAQ: Formulardaten typsicher validieren

1Warum reicht ein TypeScript-Interface für Formulardaten nicht aus?
Typen verschwinden bei Type Erasure zur Laufzeit vollständig. Formulardaten kommen immer als unknown oder string an, ein Interface prüft zur Laufzeit gar nichts.
2Was bedeutet Schema-first-Validierung mit z.infer?
Das Schema ist die einzige Quelle der Wahrheit, z.infer leitet den TypeScript-Typ automatisch daraus ab, sodass Typ und Prüfung nie auseinanderdriften.
3Wann sollte ich Valibot statt Zod verwenden?
Bei performance-kritischen Storefronts dank tree-shakebarer Architektur. Zod bleibt oft pragmatischer in Admin-Tools mit reiferem Ökosystem.
4Wie verbinde ich Zod mit nativen HTML-Formularen und FormData?
FormData wird in ein Objekt umgewandelt, Checkboxen und Zahlen explizit coerced, dann validiert schema.safeParse() das Ergebnis.
5Warum sollte ich auch API-Responses mit einem Schema validieren?
response.json() liefert ungeprüftes any. Ein Response-Schema mit .parse() deckt Vertragsbrüche zwischen Backend und Frontend sofort auf.
6Wie erhalte ich typisierte Feldfehler für die UI?
error.flatten().fieldErrors liefert ein Feldname-zu-Fehlerarray-Mapping, direkt bindbar an Formularfelder. error.format() erhält verschachtelte Strukturen.
7Wie funktioniert Cross-Field-Validierung wie eine Passwortbestätigung?
.refine() auf dem gesamten Objekt hat Zugriff auf alle Felder gleichzeitig. Für mehrere Regeln eignet sich .superRefine() mit mehreren addIssue()-Aufrufen.
8Wie handhabe ich bedingt erforderliche Felder in einem Schema?
Über .refine() auf Objektebene mit passendem path, damit der Fehler dem richtigen Feld zugeordnet wird, etwa der bedingt erforderlichen Rechnungsadresse.
9Erhöht Schema-Validierung die Bundle-Größe spürbar?
Zod fällt als zusammenhängender Block ins Gewicht, Valibot bleibt dank Tree-Shaking meist bei wenigen Kilobyte, abhängig von den genutzten Validatoren.
10Ersetzt Schema-Validierung Backend-Validierung?
Nein, Frontend-Validierung verbessert nur die Nutzererfahrung. Serverseitige Validierung bleibt zwingend erforderlich, da Requests auch von außerhalb kommen können.