Native HTML-Validierung deckt einfache Fälle ab — required, type="email", pattern. Sobald eine Regel komplexer wird (zwei Passwörter müssen übereinstimmen, ein Datum muss in der Zukunft liegen, ein Username muss frei sein), reicht das nicht mehr. Hier kommen Schema-Libraries ins Spiel: Du beschreibst die Regeln einmal als Datenstruktur, die Library prüft sie und gibt dir Fehlermeldungen zurück. Die zwei Platzhirsche im JavaScript-Ökosystem sind Zod und Valibot.

Warum Schema-Validierung?

Drei Gründe, die in einer realen Anwendung schnell relevant werden:

  • Client und Server sollen die gleichen Regeln prüfen. Ein Schema lässt sich in einer Datei definieren und an beiden Stellen importieren. Manuelle Validierungs-Funktionen würde man duplizieren.
  • TypeScript-Typen aus dem Schema ableiten. Aus z.object({ email: z.string().email() }) kannst du dir den TypeScript-Typ generieren lassen. Eine Quelle der Wahrheit, kein Drift zwischen Schema und Type.
  • Fehler-Objekte sind strukturiert. Statt 'Email ist ungültig' als String bekommst du ein Objekt { email: ['Ungültig'] }. Das lässt sich gezielt im UI auswerten.

Zod und Valibot lösen dasselbe Problem mit ähnlicher API. Wer kompakte Bundle-Größe braucht, nimmt Valibot; wer das größere Ökosystem schätzt, bleibt bei Zod.

Beispiel mit Zod

Der Login-Form aus dem vorigen Artikel — diesmal mit Zod-Validierung.

bash Installation
npm install zod
ts src/lib/schemas/login.ts
import { z } from 'zod';

export const loginSchema = z.object({
    email: z.string().email('Ungültige E-Mail'),
    password: z.string().min(8, 'Mindestens 8 Zeichen'),
});

export type LoginInput = z.infer<typeof loginSchema>;
svelte LoginForm.svelte
<script lang="ts">
    import { loginSchema } from '$lib/schemas/login';

    let email = $state('');
    let password = $state('');

    // Live-Validation: bei jeder Eingabe neu prüfen
    let result = $derived(loginSchema.safeParse({ email, password }));

    // Hilfs-Lookup: Fehler je Feld
    let errors = $derived.by(() => {
        if (result.success) return {} as Record<string, string>;
        const e: Record<string, string> = {};
        for (const issue of result.error.issues) {
            const field = issue.path[0] as string;
            e[field] = issue.message;
        }
        return e;
    });

    async function handleSubmit(event: SubmitEvent) {
        event.preventDefault();
        if (!result.success) return;
        await fetch('/api/login', {
            method: 'POST',
            body: JSON.stringify(result.data),
        });
    }
</script>

<form onsubmit={handleSubmit}>
    <label>
        E-Mail
        <input type="email" bind:value={email} />
        {#if errors.email}<small>{errors.email}</small>{/if}
    </label>

    <label>
        Passwort
        <input type="password" bind:value={password} />
        {#if errors.password}<small>{errors.password}</small>{/if}
    </label>

    <button type="submit" disabled={!result.success}>Einloggen</button>
</form>

Der Code im Detail:

  1. safeParse prüft ohne zu werfen. result ist entweder { success: true, data } oder { success: false, error } — perfekt für reaktive Verarbeitung.
  2. $derived baut beide Folgewerte: result rein aus den Eingaben, errors als Mapping Feld → Fehlertext. Ändern sich die Eingaben, aktualisieren sich beide automatisch.
  3. Submit ist nur möglich, wenn result.success. Der Button ist sonst deaktiviert, der Submit-Handler bricht ab.
  4. Type-Inference sorgt dafür, dass result.data automatisch als { email: string; password: string } getypt ist — abgeleitet aus dem Schema.

Live vs. on-blur vs. on-submit

Live-Validation (jeder Tastendruck löst eine Prüfung aus) ist nicht immer wünschenswert. Wenn der Nutzer noch tippt, will er nicht sofort Ungültige E-Mail lesen, nur weil er bei anna@ gerade ist. Drei verbreitete Strategien:

StrategieWann prüft die UI?Vor- und Nachteile
LiveBei jedem TastendruckSofort-Feedback. Kann beim Tippen unangenehm sein.
Bei BlurWenn das Feld den Fokus verliertHöflicher. Erfordert eigenes Tracking, welche Felder „touched” sind.
Bei SubmitWenn der Nutzer absenden willKlassisches HTML-Verhalten. Späte Rückmeldung.

Eine pragmatische Mischung: Bei Submit immer prüfen, bei Blur ab dem ersten Touch live updaten.

svelte Touched-Tracking
<script lang="ts">
    import { loginSchema } from '$lib/schemas/login';

    let email = $state('');
    let password = $state('');
    let touched = $state<Record<string, boolean>>({});

    let result = $derived(loginSchema.safeParse({ email, password }));
    let errors = $derived.by(() => {
        if (result.success) return {} as Record<string, string>;
        const e: Record<string, string> = {};
        for (const issue of result.error.issues) {
            const field = issue.path[0] as string;
            e[field] = issue.message;
        }
        return e;
    });

    function markTouched(field: string) {
        touched[field] = true;
    }
</script>

<input type="email" bind:value={email} onblur={() => markTouched('email')} />
{#if touched.email && errors.email}<small>{errors.email}</small>{/if}

<input type="password" bind:value={password} onblur={() => markTouched('password')} />
{#if touched.password && errors.password}<small>{errors.password}</small>{/if}

So bekommt der Nutzer erst nach Verlassen des Feldes Feedback. Wenn er später korrigiert, aktualisiert sich der Fehlertext live — das ist nach dem ersten Eindruck angenehm, weil er sieht, dass seine Korrektur greift.

Beispiel mit Valibot

Valibot hat eine etwas andere API — modular per Funktion statt Method-Chaining. Das macht die Bundles kleiner, weil Tree-Shaking nur die genutzten Validatoren mitnimmt.

bash Installation
npm install valibot
ts src/lib/schemas/login.ts (Valibot)
import * as v from 'valibot';

export const loginSchema = v.object({
    email: v.pipe(v.string(), v.email('Ungültige E-Mail')),
    password: v.pipe(v.string(), v.minLength(8, 'Mindestens 8 Zeichen')),
});

export type LoginInput = v.InferOutput<typeof loginSchema>;
ts Verwendung
import * as v from 'valibot';

const result = v.safeParse(loginSchema, { email, password });

if (result.success) {
    // result.output ist typisiert
} else {
    // result.issues ist die Liste der Fehler
}

API-Unterschiede zu Zod im Überblick:

AspektZodValibot
Definitions-Stilz.string().email()v.pipe(v.string(), v.email())
Bundle-Größe (gzipped)ca. 14 KBca. 1–4 KB (je nach genutzten Validatoren)
Tree-ShakingBegrenztOptimiert
safeParse-Ergebnis{ success, data, error }{ success, output, issues }
Type-Inferencez.infer<typeof schema>v.InferOutput<typeof schema>

In allen anderen Belangen sind die Libraries vergleichbar. Wer ein neues Projekt startet und auf Bundle-Größe achtet, fährt mit Valibot oft besser. In bestehendem Code, der schon Zod nutzt, gibt es selten Anlass zum Wechsel.

Schema einmal definieren, doppelt nutzen

In SvelteKit kommt der echte Vorteil von Schema-Libraries zum Tragen: Das gleiche Schema läuft auf Client und Server.

ts src/lib/schemas/login.ts
import { z } from 'zod';

export const loginSchema = z.object({
    email: z.string().email(),
    password: z.string().min(8),
});
ts src/routes/login/+page.server.ts
import { fail } from '@sveltejs/kit';
import { loginSchema } from '$lib/schemas/login';

export const actions = {
    default: async ({ request }) => {
        const data = await request.formData();
        const result = loginSchema.safeParse({
            email: data.get('email'),
            password: data.get('password'),
        });

        if (!result.success) {
            return fail(400, {
                errors: result.error.flatten().fieldErrors,
                values: { email: data.get('email') },
            });
        }

        // ...echtes Login
        return { success: true };
    },
};

Der Vorteil ist enorm: Eine einzige Quelle für Validierungs-Regeln. Wenn du das Passwort-Minimum von 8 auf 12 änderst, ändert sich beides gleichzeitig — kein Drift, keine vergessene Stelle. Mehr zu Form Actions im Artikel SvelteKit Form Actions.

Eine Wrapper-Komponente: Field

Wenn dieselben Patterns (Label, Eingabefeld, Fehler-Anzeige) in jedem Formular wiederkehren, lohnt sich eine Wrapper-Komponente.

svelte src/lib/components/Field.svelte
<script lang="ts">
    type Props = {
        label: string;
        error?: string;
        touched?: boolean;
        id?: string;
        children: import('svelte').Snippet<[{ id: string; ariaInvalid: boolean }]>;
    };

    let { label, error, touched, id = crypto.randomUUID(), children }: Props = $props();

    let showError = $derived(touched && !!error);
</script>

<div class="field" class:has-error={showError}>
    <label for={id}>{label}</label>
    {@render children({ id, ariaInvalid: showError })}
    {#if showError}
        <small>{error}</small>
    {/if}
</div>
svelte Verwendung
<Field label="E-Mail" error={errors.email} touched={touched.email}>
    {#snippet children({ id, ariaInvalid })}
        <input
            {id}
            type="email"
            bind:value={email}
            aria-invalid={ariaInvalid}
            onblur={() => touched.email = true}
        />
    {/snippet}
</Field>

Die Field-Komponente kümmert sich um:

  • Label und For-Anbindung mit eindeutiger ID.
  • Error-Anzeige nur bei touched.
  • aria-invalid-Attribut für Screenreader.
  • Klassen-Schalter has-error für eigenes Styling.

Damit bleibt der eigentliche Form-Code kompakt und konsistent.

Häufige Stolperfallen

Schema bei jedem Render neu erzeugen. Wenn du z.object({...}) innerhalb eines $derived aufrufst, wird das Schema bei jeder Eingabe neu gebaut. Das ist Verschwendung. Das Schema gehört in eine separate Datei oder zumindest außerhalb des reaktiven Scopes.

Type-Inference übersehen. z.infer<typeof schema> (Zod) bzw. v.InferOutput<typeof schema> (Valibot) liefern dir den Result-Typ kostenlos. Wer ihn manuell deklariert, baut sich Drift-Fallen ein.

Live-Validation für Username-Verfügbarkeit. Pattern und Länge live prüfen ist okay. Aber „Username schon vergeben?” sollte gedebounced an den Server gehen — sonst feuerst du bei jedem Tastendruck einen API-Request.

Server-Validation vergessen. Auch wenn der Client validiert: Der Server muss ebenfalls prüfen. Client-Code ist beliebig manipulierbar. Schema einmal definieren und an beiden Stellen aufrufen.

Fehler-Objekt-Form unterschätzt. Zods error.issues ist ein flaches Array mit Pfaden. Bei verschachtelten Schemas (Adressen mit Straße/Stadt/PLZ) musst du den Pfad mehrere Ebenen tief auflösen. .flatten().fieldErrors ist dafür die Komfort-Methode.

Validierungslogik im Markup. Komplexe Bedingungen (Konditionale Felder, abhängige Validierungen) gehören in eine eigene Funktion oder ein eigenes Schema-Konstrukt — nicht inline im <form>-Block.

Weiterführende Ressourcen

Externe Quellen

Verwandte Artikel

/ Weiter

Zurück zu Forms

Zur Übersicht