StackPractices
beginner By Mathias Paulenko

Schema-Based Data Validation with Zod in TypeScript

Validate and sanitize incoming data using Zod schemas with TypeScript inference, custom refinements, and error formatting for reliable API and form validation

Use Zod to define schemas that validate runtime data while automatically inferring TypeScript types. The solution below covers basic schemas, custom refinements, error formatting, and integration with forms and APIs for bulletproof data validation.

When to Use This

  • API request bodies must be validated before processing
  • Form inputs need both client-side and server-side validation
  • Configuration objects and environment variables require type-safe parsing. See Parse JSON for parsing structured config data.

Solution

1. Basic Schema Definition

// schemas/User.ts
import { z } from 'zod';

const UserSchema = z.object({
  id: z.string().uuid(),
  email: z.string().email(),
  name: z.string().min(2).max(50),
  age: z.number().int().min(0).max(150),
  role: z.enum(['user', 'admin', 'moderator']),
  isActive: z.boolean().default(true),
  createdAt: z.coerce.date(),
});

// Infer TypeScript type automatically
type User = z.infer<typeof UserSchema>;

2. Custom Refinements

// schemas/Password.ts
const PasswordSchema = z.string()
  .min(8, 'Password must be at least 8 characters')
  .refine(
    (val) => /[A-Z]/.test(val),
    'Password must contain an uppercase letter'
  )
  .refine(
    (val) => /[0-9]/.test(val),
    'Password must contain a number'
  )
  .refine(
    (val) => /[^a-zA-Z0-9]/.test(val),
    'Password must contain a special character'
  );

// Reusable email with domain check
const WorkEmailSchema = z.string()
  .email()
  .refine(
    (val) => val.endsWith('@company.com'),
    'Email must be a company address'
  );

3. Nested and Array Schemas

// schemas/Order.ts
const OrderItemSchema = z.object({
  productId: z.string().uuid(),
  quantity: z.number().int().positive(),
  price: z.number().positive().multipleOf(0.01),
});

const OrderSchema = z.object({
  id: z.string().uuid(),
  customerId: z.string().uuid(),
  items: z.array(OrderItemSchema).min(1),
  shippingAddress: z.object({
    street: z.string().min(5),
    city: z.string().min(2),
    zipCode: z.string().regex(/^\d{5}$/),
  }),
  total: z.number().positive(),
});

4. Error Formatting

// utils/parseErrors.ts
function formatZodErrors(error: z.ZodError): Record<string, string> {
  const formatted: Record<string, string> = {};

  error.issues.forEach((issue) => {
    const path = issue.path.join('.');
    formatted[path] = issue.message;
  });

  return formatted;
}

// API middleware
function validateBody<T>(schema: z.ZodSchema<T>) {
  return (req: Request, res: Response, next: NextFunction) => {
    const result = schema.safeParse(req.body);

    if (!result.success) {
      return res.status(400).json({
        errors: formatZodErrors(result.error),
      });
    }

    req.validatedBody = result.data;
    next();
  };
}

// Usage
app.post('/users', validateBody(UserSchema), (req, res) => {
  const user = req.validatedBody;
  // user is fully typed and validated
});

5. Form Integration

// hooks/useZodForm.ts
import { useState } from 'react';

function useZodForm<T extends z.ZodObject<any>>(schema: T) {
  const [errors, setErrors] = useState<Record<string, string>>({});

  const validate = (data: unknown): z.infer<T> | null => {
    const result = schema.safeParse(data);

    if (!result.success) {
      setErrors(formatZodErrors(result.error));
      return null;
    }

    setErrors({});
    return result.data;
  };

  return { validate, errors };
}

How It Works

  • Zod schemas define shape, type, and constraints declaratively
  • Type inference generates TypeScript types from schemas automatically
  • Refinements add custom validation logic beyond built-in checks
  • Safe parse returns discriminated unions for explicit error handling
  • Coercion transforms string inputs into proper types (dates, numbers)

Production Considerations

  • Use .strict() to reject unexpected properties and prevent injection
  • Precompile schemas for hot paths to reduce parsing overhead
  • Combine Zod with tRPC for end-to-end type-safe APIs. See REST API design.

Common Mistakes

  • Using .parse() without try-catch, crashing on invalid input
  • Not coercing query parameters and form data, which arrive as strings. See input validation.
  • Creating new schema instances on every render instead of reusing them

Advanced Solutions

Environment variable validation

Validate environment variables at startup so missing or invalid config fails fast instead of causing runtime errors:

// schemas/env.ts
import { z } from 'zod';

const EnvSchema = z.object({
  NODE_ENV: z.enum(['development', 'staging', 'production']),
  PORT: z.coerce.number().int().positive().default(3000),
  DATABASE_URL: z.string().url(),
  REDIS_URL: z.string().url().optional(),
  JWT_SECRET: z.string().min(32, 'JWT secret must be at least 32 characters'),
  JWT_EXPIRES_IN: z.string().regex(/^\d+[smhd]$/, 'Must be like "1h" or "7d"'),
  CORS_ORIGINS: z.string()
    .transform((val) => val.split(','))
    .pipe(z.array(z.string().url())),
  RATE_LIMIT_MAX: z.coerce.number().int().positive().default(100),
  LOG_LEVEL: z.enum(['debug', 'info', 'warn', 'error']).default('info'),
});

const parsed = EnvSchema.safeParse(process.env);

if (!parsed.success) {
  console.error('Invalid environment configuration:');
  parsed.error.issues.forEach((issue) => {
    console.error(`  ${issue.path.join('.')}: ${issue.message}`);
  });
  process.exit(1);
}

export const env = parsed.data;
// env is fully typed: env.PORT is number, env.CORS_ORIGINS is string[]

Discriminated unions for API responses

Handle polymorphic payloads with discriminated unions so each variant gets its own typed shape:

// schemas/webhook.ts
import { z } from 'zod';

const OrderCreatedEvent = z.object({
  type: z.literal('order.created'),
  data: z.object({
    orderId: z.string().uuid(),
    customerId: z.string().uuid(),
    amount: z.number().positive(),
    currency: z.string().length(3),
  }),
});

const OrderRefundedEvent = z.object({
  type: z.literal('order.refunded'),
  data: z.object({
    orderId: z.string().uuid(),
    refundAmount: z.number().positive(),
    reason: z.string().min(1).max(500),
  }),
});

const OrderShippedEvent = z.object({
  type: z.literal('order.shipped'),
  data: z.object({
    orderId: z.string().uuid(),
    carrier: z.string(),
    trackingNumber: z.string(),
    shippedAt: z.coerce.date(),
  }),
});

const WebhookEvent = z.discriminatedUnion('type', [
  OrderCreatedEvent,
  OrderRefundedEvent,
  OrderShippedEvent,
]);

type WebhookEvent = z.infer<typeof WebhookEvent>;

// Usage: TypeScript narrows the type based on `type`
function handleWebhook(event: WebhookEvent) {
  switch (event.type) {
    case 'order.created':
      // event.data.amount is number
      console.log(`Order ${event.data.orderId} created for ${event.data.amount}`);
      break;
    case 'order.refunded':
      // event.data.refundAmount is number
      console.log(`Refund ${event.data.refundAmount} for ${event.data.orderId}`);
      break;
    case 'order.shipped':
      // event.data.trackingNumber is string
      console.log(`Shipped via ${event.data.carrier}: ${event.data.trackingNumber}`);
      break;
  }
}

Async refinements for database checks

// schemas/registration.ts
import { z } from 'zod';
import { db } from '../db/client';

const RegistrationSchema = z.object({
  email: z.string().email(),
  username: z.string().min(3).max(20).regex(/^[a-zA-Z0-9_]+$/),
  password: z.string().min(8),
})
  .refine(
    async (data) => {
      const existing = await db.user.findUnique({
        where: { email: data.email },
      });
      return !existing;
    },
    { message: 'Email already registered', path: ['email'] },
  )
  .refine(
    async (data) => {
      const existing = await db.user.findUnique({
        where: { username: data.username },
      });
      return !existing;
    },
    { message: 'Username already taken', path: ['username'] },
  );

// Usage with async parse
async function registerUser(input: unknown) {
  const result = await RegistrationSchema.safeParseAsync(input);
  if (!result.success) {
    return { errors: formatZodErrors(result.error) };
  }
  // result.data is fully validated including async checks
  return { user: result.data };
}

Recursive schemas for nested comments

// schemas/comment.ts
import { z } from 'zod';

const CommentSchema: z.ZodType<Comment> = z.lazy(() =>
  z.object({
    id: z.string().uuid(),
    author: z.string().min(1),
    body: z.string().min(1).max(5000),
    createdAt: z.coerce.date(),
    replies: z.array(CommentSchema).default([]),
  }),
);

interface Comment {
  id: string;
  author: string;
  body: string;
  createdAt: Date;
  replies: Comment[];
}

// Validates arbitrarily nested comment trees
const commentTree = CommentSchema.parse(inputFromApi);

Zod with React Hook Form integration

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

const RegistrationFormSchema = z.object({
  firstName: z.string().min(1, 'First name is required'),
  lastName: z.string().min(1, 'Last name is required'),
  email: z.string().email('Enter a valid email'),
  password: z.string()
    .min(8, 'At least 8 characters')
    .regex(/[A-Z]/, 'Must contain an uppercase letter')
    .regex(/[0-9]/, 'Must contain a number'),
  confirmPassword: z.string(),
  acceptTerms: z.literal(true, {
    errorMap: () => ({ message: 'You must accept the terms' }),
  }),
}).refine(
  (data) => data.password === data.confirmPassword,
  { message: 'Passwords do not match', path: ['confirmPassword'] },
);

type RegistrationForm = z.infer<typeof RegistrationFormSchema>;

function RegistrationForm() {
  const {
    register,
    handleSubmit,
    formState: { errors, isSubmitting },
  } = useForm<RegistrationForm>({
    resolver: zodResolver(RegistrationFormSchema),
  });

  const onSubmit = async (data: RegistrationForm) => {
    // data is fully typed and validated
    await api.register(data);
  };

  return (
    <form onSubmit={handleSubmit(onSubmit)}>
      <input {...register('firstName')} />
      {errors.firstName && <span>{errors.firstName.message}</span>}
      <input {...register('email')} type="email" />
      {errors.email && <span>{errors.email.message}</span>}
      <input {...register('password')} type="password" />
      {errors.password && <span>{errors.password.message}</span>}
      <button type="submit" disabled={isSubmitting}>Register</button>
    </form>
  );
}

Frequently Asked Questions

How does Zod compare to Yup or Joi?

Zod offers native TypeScript inference without a separate type declaration. It has zero dependencies and tree-shakes well.

Can Zod validate async operations?

Yes. Use .refine() with an async function for database uniqueness checks or external validation.