Menu
Back to Blog
1 min read
Web Development

Zod Schema Validation: Type-Safe Validation für TypeScript

Umfassender Guide zu Zod für Schema-Validierung. TypeScript-Integration, API-Validation, Form-Handling und Best Practices für robuste Anwendungen.

ZodSchema ValidationTypeScript ValidationForm ValidationAPI ValidationRuntime Validation
Zod Schema Validation: Type-Safe Validation für TypeScript

Zod Schema Validation: Type-Safe Validation für TypeScript

Meta-Description: Umfassender Guide zu Zod für Schema-Validierung. TypeScript-Integration, API-Validation, Form-Handling und Best Practices für robuste Anwendungen.

Keywords: Zod, Schema Validation, TypeScript Validation, Form Validation, API Validation, Runtime Validation, Type Safety


Einführung

Zod ist die führende Schema-Validierungsbibliothek für TypeScript. Sie bietet Runtime-Validierung mit automatischer Type-Inferenz – eine perfekte Brücke zwischen TypeScript's Compile-Time-Checks und der Realität von externen Daten.


Das Problem

// TypeScript prüft nur zur Compile-Time
interface User {
  name: string;
  email: string;
  age: number;
}

// Aber was passiert zur Runtime?
const userData = await fetch('/api/user').then(r => r.json());

// userData könnte ALLES sein - TypeScript vertraut blind
const user: User = userData; // Keine Runtime-Prüfung!

Die Lösung: Zod

import { z } from 'zod';

// Schema definieren
const UserSchema = z.object({
  name: z.string().min(2),
  email: z.string().email(),
  age: z.number().int().positive()
});

// Type automatisch inferieren
type User = z.infer<typeof UserSchema>;
// { name: string; email: string; age: number }

// Runtime-Validierung
const userData = await fetch('/api/user').then(r => r.json());
const user = UserSchema.parse(userData); // Wirft bei ungültigen Daten!

// Oder mit safeParse für Error-Handling
const result = UserSchema.safeParse(userData);
if (result.success) {
  console.log(result.data); // Typisiert als User
} else {
  console.error(result.error.errors);
}

Basis-Typen

import { z } from 'zod';

// Primitive Typen
const stringSchema = z.string();
const numberSchema = z.number();
const booleanSchema = z.boolean();
const dateSchema = z.date();
const bigintSchema = z.bigint();
const symbolSchema = z.symbol();
const undefinedSchema = z.undefined();
const nullSchema = z.null();
const voidSchema = z.void();
const anySchema = z.any();
const unknownSchema = z.unknown();
const neverSchema = z.never();

// Literale
const tuna = z.literal('tuna');
const twelve = z.literal(12);
const isTrue = z.literal(true);

// Enums
const FishEnum = z.enum(['Salmon', 'Tuna', 'Trout']);
type FishEnum = z.infer<typeof FishEnum>; // 'Salmon' | 'Tuna' | 'Trout'

// Native Enums
enum Fruits {
  Apple,
  Banana
}
const FruitEnum = z.nativeEnum(Fruits);

String Validierung

const stringSchema = z.string()
  // Länge
  .min(5, 'Mindestens 5 Zeichen')
  .max(100, 'Maximal 100 Zeichen')
  .length(10, 'Exakt 10 Zeichen')

  // Format
  .email('Ungültige E-Mail')
  .url('Ungültige URL')
  .uuid('Ungültige UUID')
  .cuid('Ungültige CUID')
  .datetime('Ungültiges Datum')
  .ip('Ungültige IP')

  // Regex
  .regex(/^[a-z]+$/, 'Nur Kleinbuchstaben')

  // Transformationen
  .trim()
  .toLowerCase()
  .toUpperCase()

  // Custom
  .refine(val => val.includes('@'), 'Muss @ enthalten');

// Praktisches Beispiel
const UsernameSchema = z.string()
  .min(3, 'Username zu kurz')
  .max(20, 'Username zu lang')
  .regex(/^[a-zA-Z0-9_]+$/, 'Nur Buchstaben, Zahlen und _')
  .toLowerCase();

Number Validierung

const numberSchema = z.number()
  // Constraints
  .gt(0, 'Größer als 0')
  .gte(0, 'Größer oder gleich 0')
  .lt(100, 'Kleiner als 100')
  .lte(100, 'Kleiner oder gleich 100')
  .positive('Muss positiv sein')
  .negative('Muss negativ sein')
  .nonpositive()
  .nonnegative()
  .multipleOf(5, 'Muss durch 5 teilbar sein')
  .int('Muss Ganzzahl sein')
  .finite()
  .safe(); // JavaScript safe integer

// Praktisches Beispiel: Preis
const PriceSchema = z.number()
  .positive('Preis muss positiv sein')
  .multipleOf(0.01, 'Maximal 2 Dezimalstellen')
  .max(1000000, 'Preis zu hoch');

Object Schemas

// Basis Object
const UserSchema = z.object({
  name: z.string(),
  email: z.string().email(),
  age: z.number().int().positive()
});

// Optional & Default
const UserWithDefaultsSchema = z.object({
  name: z.string(),
  email: z.string().email(),
  age: z.number().optional(),           // number | undefined
  role: z.string().default('user'),     // Hat immer einen Wert
  isActive: z.boolean().nullable()      // boolean | null
});

// Extend
const AdminSchema = UserSchema.extend({
  permissions: z.array(z.string())
});

// Pick & Omit
const UserNameOnly = UserSchema.pick({ name: true });
const UserWithoutAge = UserSchema.omit({ age: true });

// Partial & Required
const PartialUser = UserSchema.partial();           // Alle optional
const RequiredUser = PartialUser.required();        // Alle required

// Strict Mode
const StrictUser = UserSchema.strict(); // Wirft bei extra Keys

// Passthrough & Strip
const PassthroughUser = UserSchema.passthrough(); // Behält extra Keys
const StrippedUser = UserSchema.strip();          // Entfernt extra Keys (default)

Array & Tuple

// Array
const StringArraySchema = z.array(z.string());
const NumberArraySchema = z.array(z.number())
  .min(1, 'Mindestens 1 Element')
  .max(10, 'Maximal 10 Elemente')
  .nonempty('Darf nicht leer sein');

// Tuple
const CoordinateSchema = z.tuple([
  z.number(), // x
  z.number(), // y
  z.number().optional() // z (optional)
]);

type Coordinate = z.infer<typeof CoordinateSchema>;
// [number, number, number?]

// Rest Elements
const StringsThenNumbers = z.tuple([z.string(), z.string()])
  .rest(z.number());
// [string, string, ...number[]]

Union & Discriminated Union

// Union
const StringOrNumber = z.union([z.string(), z.number()]);

// Shorthand
const StringOrNull = z.string().nullable(); // string | null
const StringOrUndefined = z.string().optional(); // string | undefined

// Discriminated Union (performanter)
const ResultSchema = z.discriminatedUnion('status', [
  z.object({ status: z.literal('success'), data: z.string() }),
  z.object({ status: z.literal('error'), error: z.string() })
]);

type Result = z.infer<typeof ResultSchema>;
// { status: 'success'; data: string } | { status: 'error'; error: string }

Transformationen

// Transform Output
const NumberFromString = z.string().transform(val => parseInt(val, 10));
// Input: string, Output: number

// Preprocessing
const NumberSchema = z.preprocess(
  (val) => {
    if (typeof val === 'string') return parseInt(val, 10);
    return val;
  },
  z.number()
);

// Praktisches Beispiel: API Response
const ApiResponseSchema = z.object({
  created_at: z.string().transform(val => new Date(val)),
  price_cents: z.number().transform(val => val / 100),
  is_active: z.union([z.boolean(), z.literal('true'), z.literal('false')])
    .transform(val => val === true || val === 'true')
});

// Input: { created_at: "2024-01-15", price_cents: 1999, is_active: "true" }
// Output: { created_at: Date, price_cents: 19.99, is_active: true }

Custom Validation mit Refine

// Einfaches Refine
const PasswordSchema = z.string()
  .min(8)
  .refine(
    (val) => /[A-Z]/.test(val),
    'Muss Großbuchstaben enthalten'
  )
  .refine(
    (val) => /[0-9]/.test(val),
    'Muss Zahlen enthalten'
  );

// Superrefine für komplexe Logik
const SignupSchema = z.object({
  password: z.string().min(8),
  confirmPassword: z.string()
}).superRefine((data, ctx) => {
  if (data.password !== data.confirmPassword) {
    ctx.addIssue({
      code: z.ZodIssueCode.custom,
      message: 'Passwörter stimmen nicht überein',
      path: ['confirmPassword']
    });
  }
});

// Async Validation
const UniqueEmailSchema = z.string().email().refine(
  async (email) => {
    const exists = await checkEmailExists(email);
    return !exists;
  },
  'E-Mail bereits vergeben'
);

Integration mit React Hook Form

import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';

const SignupSchema = z.object({
  name: z.string().min(2, 'Name zu kurz'),
  email: z.string().email('Ungültige E-Mail'),
  password: z.string().min(8, 'Mindestens 8 Zeichen')
});

type SignupData = z.infer<typeof SignupSchema>;

function SignupForm() {
  const {
    register,
    handleSubmit,
    formState: { errors }
  } = useForm<SignupData>({
    resolver: zodResolver(SignupSchema)
  });

  const onSubmit = (data: SignupData) => {
    console.log(data); // Typsicher!
  };

  return (
    <form onSubmit={handleSubmit(onSubmit)}>
      <input {...register('name')} />
      {errors.name && <span>{errors.name.message}</span>}

      <input {...register('email')} />
      {errors.email && <span>{errors.email.message}</span>}

      <input type="password" {...register('password')} />
      {errors.password && <span>{errors.password.message}</span>}

      <button type="submit">Registrieren</button>
    </form>
  );
}

API Route Validation (Next.js)

// app/api/users/route.ts
import { z } from 'zod';
import { NextRequest, NextResponse } from 'next/server';

const CreateUserSchema = z.object({
  name: z.string().min(2),
  email: z.string().email(),
  role: z.enum(['admin', 'user']).default('user')
});

export async function POST(request: NextRequest) {
  try {
    const body = await request.json();
    const data = CreateUserSchema.parse(body);

    // data ist typsicher
    const user = await createUser(data);

    return NextResponse.json(user, { status: 201 });
  } catch (error) {
    if (error instanceof z.ZodError) {
      return NextResponse.json(
        { errors: error.errors },
        { status: 400 }
      );
    }
    throw error;
  }
}

Error Handling

import { z } from 'zod';

const UserSchema = z.object({
  name: z.string().min(2),
  email: z.string().email()
});

try {
  UserSchema.parse({ name: 'A', email: 'invalid' });
} catch (error) {
  if (error instanceof z.ZodError) {
    // Formatierte Errors
    console.log(error.format());
    /*
    {
      name: { _errors: ['String must contain at least 2 character(s)'] },
      email: { _errors: ['Invalid email'] }
    }
    */

    // Flache Error-Liste
    console.log(error.flatten());
    /*
    {
      formErrors: [],
      fieldErrors: {
        name: ['String must contain at least 2 character(s)'],
        email: ['Invalid email']
      }
    }
    */
  }
}

Fazit

Zod bietet:

  1. Runtime + Compile-Time Safety: Validierung wo TypeScript aufhört
  2. Type Inference: Keine doppelten Definitionen
  3. Composability: Schemas kombinieren und erweitern
  4. Framework-Agnostisch: React, Vue, Node.js, etc.

Für jede Anwendung mit externen Daten ist Zod unverzichtbar.


Bildprompts

  1. "Shield protecting data, validation concept, type-safe illustration"
  2. "TypeScript and runtime validation merging, code security concept"
  3. "Form with checkmarks appearing as user types, validation feedback visualization"

Quellen