Zum Inhalt springen

Typsicherheit zur Laufzeit mit Zod: Validierung an jeder Grenze

Das Typsystem von TypeScript endet beim Kompilieren — Zod schließt die Lücke und validiert Daten zur Laufzeit dort, wo es wirklich darauf ankommt.

5 Min. Lesezeit
TypeScript-Code mit Zod-Schemadefinitionen, die API-Antwortgrenzen validieren

TypeScript gibt dir innerhalb deiner Codebasis Sicherheit. Sobald Daten eine Grenze überschreiten — eine API-Antwort, eine Formulareingabe, eine Umgebungsvariable, die Payload einer Message Queue —, löst sich diese Sicherheit in Luft auf. Du castest nach unknown, erzwingst den Typ mit as und hoffst, dass die Form zu dem deklarierten Typ passt. Zod behebt das, indem es das Schema zur einzigen Quelle der Wahrheit sowohl für den Laufzeit-Validator als auch für den TypeScript-Typ macht.

Dabei geht es nicht nur um Validierung. Es geht darum, die Typdefinition an den einzigen Ort zu verlagern, an dem sie tatsächlich überprüft werden kann: die Grenze selbst.

Das Grenzproblem

Wenn du const user = await fetchUser(id) schreibst und TypeScript den Typ User ableitet, basiert diese Ableitung auf deiner Rückgabetyp-Annotation — nicht auf dem, was die API tatsächlich zurückgegeben hat. Das Netzwerk kennt deine Interfaces nicht.

tstypescript
// ❌ Type assertion with no runtime guarantee
async function fetchUser(id: string): Promise<User> {
  const res = await fetch(`/api/users/${id}`);
  return res.json() as User; // TypeScript trusts this. The runtime doesn't care.
}
 
// ✅ Schema validates at runtime; type is derived from the schema
import { z } from "zod";
 
const UserSchema = z.object({
  id: z.string().uuid(),
  email: z.string().email(),
  role: z.enum(["admin", "editor", "viewer"]),
  createdAt: z.coerce.date(),
});
 
type User = z.infer<typeof UserSchema>;
 
async function fetchUser(id: string): Promise<User> {
  const res = await fetch(`/api/users/${id}`);
  const data = await res.json();
  return UserSchema.parse(data); // throws ZodError if shape is wrong
}

Der Typ wird mit z.infer aus dem Schema abgeleitet. Das Schema ist die einzige Quelle der Wahrheit — eine Typabweichung zwischen deinem Validator und deinen TypeScript-Typen ist unmöglich, weil es sich um dasselbe Artefakt handelt.

parse vs. safeParse: bewusst wählen

Zod bietet dir zwei Parsing-Modi mit unterschiedlicher Fehlersemantik. Wählst du den falschen, verschluckst du entweder Fehler oder handelst dir unbehandelte Exceptions ein.

tstypescript
import { z, ZodError } from "zod";
 
const OrderSchema = z.object({
  id: z.string(),
  total: z.number().positive(),
  status: z.enum(["pending", "paid", "shipped", "cancelled"]),
});
 
// parse — throws ZodError on failure. Good for:
// - Application startup (env vars, config)
// - Internal boundaries you control
// - Places where failure should be loud
const order = OrderSchema.parse(rawData);
 
// safeParse — returns { success, data } | { success: false, error }. Good for:
// - User input validation
// - External API responses you want to handle gracefully
// - Any boundary where you need structured error reporting
const result = OrderSchema.safeParse(rawData);
 
if (!result.success) {
  const fieldErrors = result.error.flatten().fieldErrors;
  return Response.json({ errors: fieldErrors }, { status: 422 });
}
 
// result.data is fully typed here
processOrder(result.data);
~

Verwende parse beim Start und an internen Grenzen, wo ein Fehlschlag ein Programmierfehler ist. Verwende safeParse an nutzerseitigen oder externen Grenzen, an denen du strukturiertes Feedback zurückgeben musst.

Umgebungsvariablen beim Start

Unvalidierte Umgebungsvariablen sind eine der häufigsten Ursachen für stille Ausfälle in der Produktion. Eine App, die mit DATABASE_URL=undefined startet und drei Requests später abstürzt, ist schlimmer als eine, die den Start von vornherein verweigert.

tstypescript
import { z } from "zod";
 
const EnvSchema = z.object({
  NODE_ENV: z.enum(["development", "test", "production"]),
  DATABASE_URL: z.string().url(),
  JWT_SECRET: z.string().min(32),
  PORT: z.coerce.number().int().positive().default(3000),
  REDIS_URL: z.string().url().optional(),
});
 
// Runs once at startup. Throws immediately if anything is wrong.
const env = EnvSchema.parse(process.env);
 
export { env };

Importiere überall env statt process.env. Jeder Verbraucher erhält vollständig typisierte, validierte Werte. Schluss mit process.env.PORT! samt einer Non-Null-Assertion, bei der du dir nicht wirklich sicher bist.

Schema-Komposition für komplexe Domänen

Schemas sind einfach nur Werte. Komponiere sie genauso, wie du Typen komponierst — durch Vererbung, Intersection und Extension.

tstypescript
import { z } from "zod";
 
// Base schema shared by create and update
const ProductBase = z.object({
  name: z.string().min(1).max(120),
  price: z.number().positive(),
  category: z.string(),
});
 
// Create requires all fields
const CreateProductSchema = ProductBase;
 
// Update allows partial fields but requires id
const UpdateProductSchema = ProductBase.partial().extend({
  id: z.string().uuid(),
});
 
// API response includes server-generated fields
const ProductResponseSchema = ProductBase.extend({
  id: z.string().uuid(),
  createdAt: z.coerce.date(),
  updatedAt: z.coerce.date(),
});
 
type CreateProduct = z.infer<typeof CreateProductSchema>;
type UpdateProduct = z.infer<typeof UpdateProductSchema>;
type Product = z.infer<typeof ProductResponseSchema>;

.partial(), .extend(), .pick(), .omit() — die Kombinatoren von Zod entsprechen fast eins zu eins den Utility Types von TypeScript, werden aber zur Laufzeit ausgewertet. Du pflegst nicht länger parallele Typdefinitionen und Schema-Objekte.

Webhook- und Queue-Payloads validieren

Webhooks und Message Queues sind besonders riskante Grenzen. Die Payload kommt von einem Drittanbieter, ganz ohne TypeScript-Vertrag. Teams casten sie oft einfach auf einen Typ und machen weiter — bis der Anbieter die Form der Payload ändert.

tstypescript
import { z } from "zod";
 
// Stripe webhook event — validate what you actually use
const StripeCheckoutEventSchema = z.object({
  type: z.literal("checkout.session.completed"),
  data: z.object({
    object: z.object({
      id: z.string(),
      customer_email: z.string().email().nullable(),
      amount_total: z.number().int().nullable(),
      metadata: z.record(z.string()).default({}),
    }),
  }),
});
 
export async function handleWebhook(rawBody: unknown) {
  const result = StripeCheckoutEventSchema.safeParse(rawBody);
 
  if (!result.success) {
    // Log the raw payload and the validation error together for debugging
    console.error("Unexpected webhook shape", {
      errors: result.error.flatten(),
      rawBody,
    });
    // Return 200 to avoid retries for unrecognized event types
    return;
  }
 
  const { data } = result;
  await fulfillOrder(data.data.object);
}

Strikte Validierung des Event-Typs deckt Schema-Drift zwischen deinem Handler und der vorgelagerten API auf. Wenn Stripe seine Payload ändert, schlägt deine Validierung in Staging laut fehl, statt in der Produktion still Daten zu verfälschen.

Transformation während des Parsens

Validierung und Transformation werden oft als getrennte Schritte behandelt. Zod führt sie zusammen. Mit .transform() kannst du Daten direkt beim Parsen umwandeln, normalisieren und umformen — der Ausgabetyp spiegelt die Transformation wider.

tstypescript
import { z } from "zod";
 
const SearchParamsSchema = z.object({
  // Query params arrive as strings; coerce and bound them
  page: z.coerce.number().int().positive().default(1),
  limit: z.coerce.number().int().min(1).max(100).default(20),
  // Normalize comma-separated tags into an array
  tags: z
    .string()
    .optional()
    .transform((v) => (v ? v.split(",").map((t) => t.trim().toLowerCase()) : [])),
  // Accept multiple sort directions
  sortDir: z
    .enum(["asc", "desc", "ASC", "DESC"])
    .transform((v) => v.toLowerCase() as "asc" | "desc")
    .default("desc"),
});
 
type SearchParams = z.infer<typeof SearchParamsSchema>;
// { page: number; limit: number; tags: string[]; sortDir: "asc" | "desc" }
 
export function GET(req: Request) {
  const url = new URL(req.url);
  const params = SearchParamsSchema.parse(Object.fromEntries(url.searchParams));
  // params is fully typed and normalized — no downstream casting needed
  return queryProducts(params);
}

Der Ausgabetyp von SearchParams spiegelt exakt die transformierte Form wider, nicht die Rohdaten. Nachgelagerter Code bekommt nie rohe Strings zu Gesicht, die sich als Zahlen ausgeben.

Die wichtigsten Erkenntnisse

  1. TypeScript-Typen existieren nur zur Kompilierzeit — alle Daten, die eine Prozessgrenze überschreiten, brauchen eine Laufzeitvalidierung, um vertrauenswürdig zu sein.
  2. Leite Typen von Schemas ab, nicht umgekehrt — z.infer<typeof Schema> beseitigt die Abweichung zwischen Typ und Validator, die subtile Bugs verursacht.
  3. Verwende parse an Grenzen beim Start und safeParse an nutzerseitigen Grenzen — passe den Fehlermodus an den jeweiligen Kontext an.
  4. Validiere Umgebungsvariablen beim Start des Prozesses — scheitere laut und sofort, statt mitten in einem Request auf ein undefined zu stoßen.
  5. Schemas sind komponierbar — behandle sie wie Typen: erweitern, auswählen, weglassen und verschneiden, statt Felddefinitionen zu duplizieren.
  6. Kombiniere Validierung und Transformation — .transform() liefert dir eine typisierte Ausgabe, die die tatsächliche Form widerspiegelt, sodass du sie später in deiner Business-Logik nicht mehr konvertieren musst.
Wilfredo Rujel

Wilfredo Rujel

Full-Stack-Softwareentwickler

Diesen Beitrag teilenX