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.
Inhaltsverzeichnis
- 1. Warum TypeScript-Typen zur Laufzeit nichts wert sind
- 2. Schema-first: Typen aus der Laufzeit-Validierung ableiten
- 3. Ein Zod-Schema für ein Checkout-Formular in der Praxis
- 4. Valibot als leichtgewichtige Alternative zu Zod
- 5. Validierung mit nativen HTML-Formularen und FormData verbinden
- 6. Typisierte API-Schicht: Requests und Responses validieren
- 7. Fehlermeldungen und typisierte Feldfehler für die UI
- 8. Eigene Validierungsregeln und Cross-Field-Validierung
- 9. Manuelle vs. schema-basierte Validierung im Vergleich
- 10. Zusammenfassung
- 11. FAQ
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.