Runtime-Daten in TypeScript mit Zod validieren
1. Überblick
TypeScript-Typen werden nach der Kompilierung gelöscht. Sie schützen dich daher nicht vor falsch formatierten JSON von APIs, von Benutzern eingereichte Formulare oder fehlende Umgebungsvariablen.
Dieser Artikel zeigt dir, wie du Zod 4 zur Validierung von Daten zur Laufzeit nutzt. Du wirst ein Schema einmal deklarieren und daraus Typen ableiten. Du erfährst auch, wie du Fehler sauber handhabst, Tests schreibst und einige häufige Fallstricke vermeidest.
2. Warum TypeScript-Typen zur Laufzeit nicht ausreichen
Schau dir diesen vertrauten Code an:
interface Customer {
id: number;
email: string;
}
const raw = '{"id": "42", "email": null}';
const customer = JSON.parse(raw) as Customer;
console.log(customer.id + 1);
console.log(customer.email.toLowerCase());
JSON.parse gibt any zurück. Das as Customer ist nur ein Versprechen an den Compiler, ohne echte Überprüfung. Bei der Ausführung:
421
TypeError: Cannot read properties of null (reading 'toLowerCase')
id ist der String "42", daher wird die Addition zu einer Stringverkettung. email ist null, daher stürzt das Programm ab. Der Compiler sagt nichts. Du brauchst eine echte Überprüfungsschicht zur Laufzeit. Zod macht genau das.
3. Installation
Zod hat keine anderen Abhängigkeiten und läuft sowohl auf Node.js als auch im Browser:
npm install zod
Damit Zod Typen korrekt ableitet, musst du den Strict Mode in tsconfig.json aktivieren:
{
"compilerOptions": {
"strict": true
}
}
Hinweis: Ohne
"strict": truekönnen optionale Felder falsch abgeleitet werden. Du wirst den Großteil des Vorteils durch Typisierung verlieren.
4. Schema definieren und Typen ableiten
Ein Schema beschreibt die Struktur der Daten, die du erwartest:
import { z } from "zod";
const CustomerSchema = z.object({
id: z.number().int().positive(),
email: z.email(),
fullName: z.string().min(2).max(100),
phone: z.string().optional(),
tier: z.enum(["FREE", "PRO", "ENTERPRISE"]).default("FREE"),
});
type Customer = z.infer<typeof CustomerSchema>;
Hier ist, was die einzelnen Teile tun:
z.number().int().positive()verlangt eine positive ganze Zahl.z.email()überprüft, ob der String ein gültiges E-Mail-Format hat. In Zod 4 werden String-Formate wie Email, URL und UUID auf oberster Ebene deklariert..optional()ermöglicht, dass ein Feld fehlt oder den Wertundefinedhat..default("FREE")füllt den Wert automatisch aus, wenn die Eingabe keintier-Feld hat.z.inferleitet einen TypeScript-Typ aus dem Schema ab.
Wenn du über Customer fährst, siehst du den äquivalenten Typ:
type Customer = {
id: number;
email: string;
fullName: string;
phone?: string | undefined;
tier: "FREE" | "PRO" | "ENTERPRISE";
}
Die Stärke liegt darin: Das Schema ist die einzige Quelle der Wahrheit. Wenn du ein Feld zum Schema hinzufügst, aktualisiert sich der Typ automatisch. Du musst nicht ein paralleles interface verwalten.
5. parse und safeParse
Zod hat zwei Hauptmethoden zur Validierung.
5.1. parse: Wirft Exception bei Fehler
const customer = CustomerSchema.parse({
id: 42,
email: "an.nguyen@example.com",
fullName: "Nguyễn Văn An",
});
console.log(customer);
{ id: 42, email: 'an.nguyen@example.com', fullName: 'Nguyễn Văn An', tier: 'FREE' }
Der zurückgegebene Wert wurde überprüft und hat den Typ Customer. Das Feld tier wird mit dem Standardwert gefüllt. Wenn die Daten ungültig sind, wirft parse einen ZodError.
5.2. safeParse: Gibt Ergebnis zurück, wirft keinen Fehler
const result = CustomerSchema.safeParse({ id: "42", email: "not-an-email" });
if (!result.success) {
console.log(result.error.issues.map((i) => `${i.path.join(".")}: ${i.code}`));
} else {
console.log(result.data.email);
}
[ 'id: invalid_type', 'email: invalid_format', 'fullName: invalid_type' ]
result ist eine discriminated union. Nachdem du result.success überprüfst, weiß TypeScript genau, welcher Zweig data und welcher error hat. Du siehst auch, dass Zod nicht beim ersten Fehler stoppt, sondern alle Fehler sammelt. Das ist praktisch, um Fehler in Formularen anzuzeigen.
Die Wahl ist einfach:
- Nutze
safeParsean den Systemgrenzen, z.B. bei Request-Body oder Formularen, wo Fehler normal sind. - Nutze
parse, wenn ungültige Daten ein Bug oder kaputte Konfiguration bedeuten und die Anwendung sofort stoppen sollte.
6. Benutzerdefinierte Fehlermeldungen und Fehlerformat
Die Standard-Fehlermeldungen von Zod sind auf Englisch und ziemlich technisch. Du kannst den error-Parameter übergeben, um sie zu ersetzen:
const SignUpSchema = z.object({
email: z.email({ error: "E-Mail ist ungültig" }),
password: z.string().min(8, { error: "Passwort mindestens 8 Zeichen" }),
});
const result = SignUpSchema.safeParse({ email: "abc", password: "123" });
if (!result.success) {
console.log(z.flattenError(result.error));
}
{
formErrors: [],
fieldErrors: {
email: [ 'E-Mail ist ungültig' ],
password: [ 'Passwort mindestens 8 Zeichen' ]
}
}
z.flattenError gruppiert Fehler nach Feldnamen. Dieses Format eignet sich gut für die Rückgabe an Frontend. Wenn du für Menschen lesbare Logs brauchst, nutze z.prettifyError(result.error), um einen mehrzeiligen String zu erhalten.
7. Coerce, Transform und Refine
Echte Daten passen selten perfekt zu einem Schema. Zod hat drei Werkzeuge, um solche Situationen zu handhaben.
7.1. z.coerce: Eingabetyp umwandeln
Query-Strings und Umgebungsvariablen sind immer Strings. z.coerce konvertiert sie vor der Validierung:
const PaginationSchema = z.object({
page: z.coerce.number().int().min(1).default(1),
size: z.coerce.number().int().max(100).default(20),
});
console.log(PaginationSchema.parse({ page: "3", size: "50" }));
{ page: 3, size: 50 }
Hinweis:
z.coerce.number()ruftNumber(input)auf. Der leere String""wird zu0und besteht die Typüberprüfung. Addiere immer Einschränkungen wie.min(1), um das zu verhindern.
7.2. transform: Nach der Validierung transformieren
const OrderCodeSchema = z
.string()
.trim()
.regex(/^ord-\d{6}$/i, { error: "Bestellcode hat falsches Format" })
.transform((code) => code.toUpperCase());
console.log(OrderCodeSchema.parse(" ord-000123 "));
ORD-000123
Nach transform können Ein- und Ausgangstypen unterschiedlich sein. Nutze z.input<typeof S> und z.output<typeof S>, um jeden Typ einzeln zu erhalten. z.infer entspricht z.output.
7.3. refine: Benutzerdefinierte Business-Logik
Es gibt Einschränkungen, die mehrere Felder gleichzeitig betreffen. Wende refine auf der Objektebene an:
const BookingSchema = z
.object({
checkIn: z.coerce.date(),
checkOut: z.coerce.date(),
})
.refine((b) => b.checkOut > b.checkIn, {
error: "Auschecktag muss nach Einchecktag liegen",
path: ["checkOut"],
});
const r = BookingSchema.safeParse({ checkIn: "2026-11-10", checkOut: "2026-11-08" });
console.log(r.success, r.error?.issues[0].path, r.error?.issues[0].message);
false [ 'checkOut' ] Auschecktag muss nach Einchecktag liegen
path bindet den Fehler an das Feld checkOut statt an das gesamte Objekt. So kann das Frontend den Fehler an der richtigen Stelle anzeigen.
8. Praktische Beispiele
8.1. Request zum Erstellen einer Bestellung validieren
Das folgende Beispiel ist eine Request-Verarbeitungsfunktion, die framework-unabhängig ist. Du kannst sie mit Express, Fastify oder Next.js verwenden:
import { z } from "zod";
const OrderItemSchema = z.object({
productId: z.uuid(),
quantity: z.number().int().min(1).max(99),
});
export const CreateOrderSchema = z.object({
customerId: z.number().int().positive(),
items: z.array(OrderItemSchema).nonempty({ error: "Bestellung muss mindestens 1 Produkt haben" }),
note: z.string().max(500).optional(),
});
export type CreateOrderRequest = z.infer<typeof CreateOrderSchema>;
export function handleCreateOrder(body: unknown) {
const result = CreateOrderSchema.safeParse(body);
if (!result.success) {
return { status: 400, body: z.flattenError(result.error).fieldErrors };
}
const order: CreateOrderRequest = result.data;
return { status: 201, body: { itemCount: order.items.length } };
}
Achte darauf, dass der body-Parameter den Typ unknown hat, nicht any. So zwingt dich TypeScript zu validieren, bevor du ihn verwendest. Nach safeParse hat result.data den vollständigen Typ. Standardmäßig entfernt z.object auch fremde Felder, daher kann der Client nicht einfach isAdmin: true zu deinem Objekt hinzufügen.
8.2. Umgebungsvariablen beim Starten validieren
Die Anwendung sollte sofort stoppen, wenn die Konfiguration ungültig ist, nicht erst beim ersten Request:
import { z } from "zod";
const EnvSchema = z.object({
NODE_ENV: z.enum(["development", "test", "production"]),
PORT: z.coerce.number().int().default(3000),
DATABASE_URL: z.url(),
});
export const env = EnvSchema.parse(process.env);
Angenommen, DATABASE_URL fehlt. Die Anwendung wirft einen ZodError sofort beim Import des Moduls und zeigt genau an, dass DATABASE_URL fehlt. Überall sonst hat env.PORT den Typ number, nicht string | undefined.
9. Tests mit Vitest schreiben
Da Schema Business-Logik enthält, brauchen sie auch Tests. Installiere zuerst Vitest:
npm install -D vitest
Schreib dann Tests für CreateOrderSchema:
import { describe, it, expect } from "vitest";
import { z } from "zod";
import { CreateOrderSchema } from "./order";
describe("CreateOrderSchema", () => {
const validItem = { productId: "3f1c2a9e-8b4d-4c1a-9f2e-1a2b3c4d5e6f", quantity: 2 };
it("akzeptiert gültige Bestellung und verwirft fremde Felder", () => {
const result = CreateOrderSchema.safeParse({
customerId: 7,
items: [validItem],
isAdmin: true,
});
expect(result.success).toBe(true);
expect(result.data).toEqual({ customerId: 7, items: [validItem] });
});
it("lehnt Bestellung ohne Produkte ab", () => {
const result = CreateOrderSchema.safeParse({ customerId: 7, items: [] });
expect(result.success).toBe(false);
expect(z.flattenError(result.error!).fieldErrors.items).toContain(
"Bestellung muss mindestens 1 Produkt haben",
);
});
it("lehnt Menge über Limit ab", () => {
const result = CreateOrderSchema.safeParse({
customerId: 7,
items: [{ ...validItem, quantity: 100 }],
});
expect(result.error?.issues[0].path).toEqual(["items", 0, "quantity"]);
});
});
Führe npx vitest run aus und du wirst sehen, dass alle drei Tests bestanden werden. Der dritte Test zeigt, dass die path des Fehlers bis zum Element im Array führt. Das ist genau die Information, die Frontend braucht, um das richtige Eingabefeld rot zu färben.
10. Häufige Fehler und wann nicht zu verwenden
Typumwandlung mit as nach Schemadefinition. Das Schreiben von body as CreateOrderRequest zerstört den gesamten Schutzmechanismus. Nimm die Daten immer aus result.data.
Asynchrones refine mit parse verwenden. Beispiel: Du überprüfst, ob eine E-Mail bereits in der Datenbank existiert. Bei asynchronem refine musst du parseAsync oder safeParseAsync aufrufen. Andernfalls wirft Zod einen Fehler.
Erwarten, dass fremde Felder abgelehnt werden. z.object verwirft fremde Felder standardmäßig stillschweigend. Wenn du einen Fehler bei zusätzlichen Feldern werfen möchtest, z.B. um Tippfehler in Konfigurationsdateien zu erfassen, nutze z.strictObject(...).
Wiederholte Validierung innerhalb der Anwendung. Du solltest nur an Systemgrenzen validieren: Requests, Responses von Drittanbieter-APIs, Message Queues, Konfigurationsdateien. Vertrau den Typen bereits validierter Daten. Erneutes Parsing in jeder Funktion verschlingt nur CPU.
Hinweis: Nutze Zod nicht in heißen Schleifen, wo Hundertausende Objekte pro Sekunde verarbeitet werden. Validierung hat echte Kosten. Dort solltest du einmal am Eingang validieren oder eine Bibliothek wählen, die Schemas zu Code kompiliert.
Schema innerhalb einer Funktion deklarieren. Wenn du ein Schema jedes Mal neu erstellst, wenn die Funktion aufgerufen wird, kostet die Anwendung zusätzliche Initialisierungszeit. Deklariere das Schema auf Modulebene und nutze es wieder.
11. Fazit
Zod schließt die Lücke zwischen Typen zur Kompilierungszeit und echten Daten zur Laufzeit. Du deklarierst ein Schema einmal, leitest Typen mit z.infer ab, und nutzt dann safeParse an den Systemgrenzen, um saubere Daten oder eine strukturierte Fehlerliste zu erhalten.
Mit coerce, transform und refine handhabst du fast alle praktischen Szenarien: Query-Strings, Datennormalisierung, Business-Regeln über mehrere Felder. Validiere früh, validiere an den Grenzen, und lass TypeScript um den Rest kümmern.