typescript

Zod Schema Patterns

Common Zod validation patterns for forms, API responses, and environment variables.

#validation #zod #forms

Practical Zod schemas for everyday validation needs.

Form Validation

import { z } from 'zod';

const signupSchema = z.object({
  email: z.string().email('Invalid email address'),
  password: z
    .string()
    .min(8, 'Password must be at least 8 characters')
    .regex(/[A-Z]/, 'Password must contain uppercase letter')
    .regex(/[0-9]/, 'Password must contain a number'),
  confirmPassword: z.string(),
  age: z.coerce.number().min(18, 'Must be 18 or older'),
  terms: z.literal(true, {
    errorMap: () => ({ message: 'You must accept the terms' }),
  }),
}).refine((data) => data.password === data.confirmPassword, {
  message: "Passwords don't match",
  path: ['confirmPassword'],
});

type SignupForm = z.infer<typeof signupSchema>;

API Response Validation

const userSchema = z.object({
  id: z.string().uuid(),
  name: z.string(),
  email: z.string().email(),
  role: z.enum(['admin', 'user', 'guest']),
  createdAt: z.string().datetime(),
  profile: z.object({
    avatar: z.string().url().nullable(),
    bio: z.string().max(500).optional(),
  }).optional(),
});

const apiResponseSchema = z.object({
  data: z.array(userSchema),
  meta: z.object({
    total: z.number(),
    page: z.number(),
    limit: z.number(),
  }),
});

// Usage with fetch
async function fetchUsers() {
  const response = await fetch('/api/users');
  const json = await response.json();
  return apiResponseSchema.parse(json); // Throws if invalid
}

Environment Variables

// env.ts
const envSchema = z.object({
  NODE_ENV: z.enum(['development', 'production', 'test']),
  DATABASE_URL: z.string().url(),
  API_KEY: z.string().min(1),
  PORT: z.coerce.number().default(3000),
  DEBUG: z.coerce.boolean().default(false),
});

export const env = envSchema.parse(process.env);

Transform & Preprocess

const dateSchema = z.string().transform((str) => new Date(str));

const trimmedString = z.string().transform((s) => s.trim());

const slugSchema = z.string().transform((s) => 
  s.toLowerCase().replace(/\s+/g, '-').replace(/[^a-z0-9-]/g, '')
);

// Preprocess for coercion with fallback
const numberWithDefault = z.preprocess(
  (val) => (val === '' ? undefined : val),
  z.coerce.number().optional()
);

Discriminated Unions

const notificationSchema = z.discriminatedUnion('type', [
  z.object({ type: z.literal('email'), email: z.string().email() }),
  z.object({ type: z.literal('sms'), phone: z.string() }),
  z.object({ type: z.literal('push'), deviceId: z.string() }),
]);

// TypeScript knows the shape based on `type`
const result = notificationSchema.parse(data);
if (result.type === 'email') {
  console.log(result.email); // Typed correctly
}