DEV Community

Atlas Whoff
Atlas Whoff

Posted on

Zod: Runtime Type Validation That Actually Catches Production Bugs

Zod: Runtime Type Validation That Actually Catches Production Bugs

TypeScript types disappear at runtime. Zod bridges the gap — it validates data at the boundaries of your system where TypeScript can't.

The Problem TypeScript Can't Solve

// TypeScript trusts you here — but what if the API returns something unexpected?
const user: User = await fetch('/api/user').then(r => r.json());
// user.age is typed as number, but the API might return '25' (string)
// TypeScript has no idea. Zod catches this.
Enter fullscreen mode Exit fullscreen mode

Basic Schema Definition

import { z } from 'zod';

const UserSchema = z.object({
  id: z.string().uuid(),
  name: z.string().min(1).max(100),
  email: z.string().email(),
  age: z.number().int().min(0).max(150).optional(),
  role: z.enum(['admin', 'user', 'moderator']),
  createdAt: z.string().datetime(),
});

// Derive TypeScript type from schema — single source of truth
type User = z.infer<typeof UserSchema>;
Enter fullscreen mode Exit fullscreen mode

Parsing vs Safe Parsing

// parse() throws on invalid data
const user = UserSchema.parse(apiResponse); // throws ZodError if invalid

// safeParse() returns a result object — better for error handling
const result = UserSchema.safeParse(apiResponse);
if (!result.success) {
  console.error('Validation failed:', result.error.flatten());
  return;
}
const user = result.data; // fully typed User
Enter fullscreen mode Exit fullscreen mode

API Request Validation

const CreatePostSchema = z.object({
  title: z.string().min(1).max(200),
  content: z.string().min(10),
  tags: z.array(z.string()).max(5).default([]),
  published: z.boolean().default(false),
});

// Express route with Zod validation
app.post('/api/posts', async (req, res) => {
  const result = CreatePostSchema.safeParse(req.body);

  if (!result.success) {
    return res.status(400).json({
      error: 'Validation failed',
      details: result.error.flatten().fieldErrors,
    });
  }

  const post = await db.posts.create({ data: result.data });
  res.json(post);
});
Enter fullscreen mode Exit fullscreen mode

Transformations

Zod can coerce and transform data during parsing:

const QuerySchema = z.object({
  // Coerce string '10' to number 10 (URL query params are always strings)
  page: z.coerce.number().int().min(1).default(1),
  limit: z.coerce.number().int().min(1).max(100).default(20),
  // Transform 'true'/'false' strings to booleans
  published: z.enum(['true', 'false']).transform(v => v === 'true').optional(),
  // Trim and lowercase email
  email: z.string().transform(s => s.trim().toLowerCase()).pipe(z.string().email()),
});
Enter fullscreen mode Exit fullscreen mode

Nested and Discriminated Unions

const NotificationSchema = z.discriminatedUnion('type', [
  z.object({ type: z.literal('email'), to: z.string().email(), subject: z.string() }),
  z.object({ type: z.literal('sms'), phone: z.string(), message: z.string() }),
  z.object({ type: z.literal('push'), deviceToken: z.string(), title: z.string() }),
]);

// TypeScript narrows the type correctly based on 'type' field
function send(notification: z.infer<typeof NotificationSchema>) {
  if (notification.type === 'email') {
    // notification.to and notification.subject are available here
  }
}
Enter fullscreen mode Exit fullscreen mode

Zod + tRPC + Prisma

The killer stack: Zod validates inputs, tRPC types the API, Prisma types the DB. Every layer is validated and typed end-to-end.

This full stack — Zod, tRPC, Prisma, NextAuth, and Stripe — comes pre-wired in the AI SaaS Starter Kit. Stop plumbing the same stack from scratch.

Top comments (0)