Published on

Zod for Runtime Validation — Parsing Untrusted Data at Every Boundary

Authors

Introduction

Zod is a TypeScript-first schema validation library that bridges the gap between static types and runtime reality. Unlike TypeScript which only exists at compile time, Zod validates data when it actually arrives—from HTTP requests, message queues, or files. This post covers schema definition patterns, parsing methods, data transformation, discriminated unions for type-safe responses, environment validation, middleware integration, and formatting errors for APIs.

Schema Definition Patterns

Build reusable schemas from simple types to complex nested structures. Compose them for DRY validation code.

import { z } from 'zod';

// Basic types with constraints
const emailSchema = z.string().email('Invalid email format').max(254);
const passwordSchema = z
  .string()
  .min(8, 'Password must be at least 8 characters')
  .regex(/[A-Z]/, 'Password must contain uppercase')
  .regex(/[0-9]/, 'Password must contain number')
  .regex(/[^a-zA-Z0-9]/, 'Password must contain special character');

const uuidSchema = z.string().uuid('Invalid UUID format');
const dateSchema = z.coerce.date().min(new Date('1900-01-01')).max(new Date());

// Composed schemas
const userProfileSchema = z.object({
  id: uuidSchema,
  email: emailSchema,
  displayName: z.string().min(2).max(100),
  bio: z.string().max(500).optional(),
  birthDate: dateSchema.optional(),
  website: z.string().url('Invalid URL').optional().or(z.literal('')),
  metadata: z.record(z.string(), z.unknown()).optional(),
});

// Branded types for extra safety
const UserId = z.string().uuid().brand<'UserId'>();
type UserId = z.infer<typeof UserId>;

const userQuerySchema = z.object({
  userId: UserId,
  include: z.array(z.enum(['posts', 'comments', 'followers'])).optional(),
});

// Arrays with constraints
const tagsSchema = z
  .array(z.string().min(1).max(50))
  .min(1, 'At least one tag required')
  .max(10, 'Maximum 10 tags')
  .refine(tags => new Set(tags).size === tags.length, {
    message: 'Tags must be unique',
  });

// Enum schemas
const roleSchema = z.enum(['admin', 'moderator', 'user', 'viewer'], {
  errorMap: () => ({ message: 'Invalid role' }),
});

const createPostSchema = z.object({
  title: z.string().min(5).max(200),
  content: z.string().min(10).max(100000),
  tags: tagsSchema,
  role: roleSchema,
  published: z.boolean().default(false),
  scheduled: z.object({
    enabled: z.boolean(),
    publishAt: dateSchema.optional(),
  }).refine(
    data => !data.enabled || data.publishAt,
    { message: 'publishAt required when scheduled is enabled', path: ['publishAt'] }
  ),
});

type CreatePostInput = z.infer<typeof createPostSchema>;

Parse vs SafeParse Methods

.parse() throws errors; .safeParse() returns a result object. Use appropriately based on error handling strategy.

import { z } from 'zod';

const schema = z.object({
  username: z.string().min(3),
  email: z.string().email(),
});

// .parse() - Use in controlled contexts with try/catch
const parseWithThrow = (data: unknown) => {
  try {
    return schema.parse(data);
  } catch (error) {
    if (error instanceof z.ZodError) {
      console.error('Validation failed:', error.flatten());
    }
    throw error; // Re-throw or handle
  }
};

// .safeParse() - Better for HTTP handlers and returning results
const parseWithResult = (data: unknown) => {
  const result = schema.safeParse(data);

  if (!result.success) {
    return {
      ok: false,
      errors: result.error.flatten(),
    };
  }

  return {
    ok: true,
    data: result.data,
  };
};

// Express middleware using safeParse
import express, { Request, Response } from 'express';

const validateRequest = (schema: z.AnyZodObject) => {
  return (req: Request, res: Response, next: express.NextFunction) => {
    const result = schema.safeParse({
      body: req.body,
      query: req.query,
      params: req.params,
    });

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

    req.body = result.data.body;
    req.query = result.data.query;
    req.params = result.data.params;
    next();
  };
};

const createPostSchema = z.object({
  body: z.object({
    title: z.string().min(5),
    content: z.string().min(10),
  }),
  params: z.object({
    userId: z.string().uuid(),
  }),
});

app.post(
  '/users/:userId/posts',
  validateRequest(createPostSchema),
  async (req, res) => {
    // req.body and req.params are now typed and validated
    const post = await createPost(req.body);
    res.json(post);
  }
);

Transforming Data with .transform()

Transform input data during validation: trim strings, normalize enums, calculate derived fields.

import { z } from 'zod';

const userSignUpSchema = z
  .object({
    email: z.string().email().transform(v => v.toLowerCase().trim()),
    password: z.string().min(8),
    passwordConfirm: z.string(),
    displayName: z.string().transform(v => v.trim()).optional(),
  })
  .refine(data => data.password === data.passwordConfirm, {
    message: 'Passwords do not match',
    path: ['passwordConfirm'],
  })
  .transform(data => {
    const { password, passwordConfirm, ...rest } = data;
    return {
      ...rest,
      password, // Still available after validation
    };
  });

// Input coercion
const dateRangeSchema = z.object({
  startDate: z.coerce.date().transform(d => {
    d.setHours(0, 0, 0, 0);
    return d;
  }),
  endDate: z.coerce.date().transform(d => {
    d.setHours(23, 59, 59, 999);
    return d;
  }),
});

// Parse JSON strings
const configSchema = z.object({
  metadata: z
    .string()
    .transform((val, ctx) => {
      try {
        return JSON.parse(val);
      } catch {
        ctx.addIssue({
          code: z.ZodIssueCode.custom,
          message: 'Invalid JSON',
        });
        return z.NEVER;
      }
    })
    .pipe(z.object({ analyticsId: z.string().optional() })),
});

// Data enrichment during validation
const createBlogPostSchema = z
  .object({
    title: z.string().min(5).max(200),
    content: z.string().min(100),
    tags: z.array(z.string()),
  })
  .transform(data => ({
    ...data,
    slug: data.title
      .toLowerCase()
      .replace(/[^a-z0-9]+/g, '-')
      .replace(/(^-|-$)/g, ''),
    wordCount: data.content.split(/\s+/).length,
    estimatedReadTime: Math.ceil(
      data.content.split(/\s+/).length / 200
    ),
    tagCount: data.tags.length,
  }));

type BlogPost = z.infer<typeof createBlogPostSchema>;
// { title, content, tags, slug, wordCount, estimatedReadTime, tagCount }

Discriminated Unions for Type-Safe Responses

Build type-safe, exhaustively-checked API responses using discriminated unions.

import { z } from 'zod';

// Success response
const successResponseSchema = z.object({
  status: z.literal('success'),
  data: z.object({
    id: z.string(),
    message: z.string(),
    timestamp: z.date(),
  }),
});

// Error response with discriminated type
const validationErrorSchema = z.object({
  status: z.literal('validation_error'),
  errors: z.record(z.array(z.string())),
});

const authErrorSchema = z.object({
  status: z.literal('auth_error'),
  message: z.string(),
  code: z.enum(['invalid_token', 'expired_token', 'missing_token']),
});

const notFoundErrorSchema = z.object({
  status: z.literal('not_found'),
  resource: z.string(),
});

const serverErrorSchema = z.object({
  status: z.literal('error'),
  message: z.string(),
  requestId: z.string(),
  code: z.string().optional(),
});

// Discriminated union
const apiResponseSchema = z.discriminatedUnion('status', [
  successResponseSchema,
  validationErrorSchema,
  authErrorSchema,
  notFoundErrorSchema,
  serverErrorSchema,
]);

type ApiResponse = z.infer<typeof apiResponseSchema>;

// Handlers return correct response shape
const handleCreateUser = async (data: unknown): Promise<ApiResponse> => {
  const result = userSchema.safeParse(data);

  if (!result.success) {
    return {
      status: 'validation_error',
      errors: result.error.flatten().fieldErrors,
    };
  }

  try {
    const user = await db.user.create({ data: result.data });
    return {
      status: 'success',
      data: {
        id: user.id,
        message: 'User created',
        timestamp: new Date(),
      },
    };
  } catch (error) {
    return {
      status: 'error',
      message: 'Failed to create user',
      requestId: generateRequestId(),
    };
  }
};

// Type-safe response handling on client
const handleResponse = (response: ApiResponse) => {
  switch (response.status) {
    case 'success':
      console.log(response.data.message);
      return response.data;

    case 'validation_error':
      Object.entries(response.errors).forEach(([field, messages]) => {
        console.error(`${field}: ${messages.join(', ')}`);
      });
      throw new Error('Validation failed');

    case 'auth_error':
      if (response.code === 'expired_token') {
        refreshAuth();
      }
      throw new Error(response.message);

    case 'not_found':
      throw new Error(`${response.resource} not found`);

    case 'error':
      logError(response.requestId, response.message);
      throw new Error(response.message);

    default:
      const exhaustive: never = response;
      throw new Error(`Unhandled response type: ${exhaustive}`);
  }
};

Environment Variable Validation

Validate configuration at startup to catch misconfiguration early.

import { z } from 'zod';

const envSchema = z.object({
  NODE_ENV: z
    .enum(['development', 'production', 'test'])
    .default('development'),
  DATABASE_URL: z.string().url('Invalid database URL'),
  REDIS_URL: z.string().url('Invalid Redis URL').optional(),
  JWT_SECRET: z.string().min(32, 'JWT secret too short'),
  JWT_EXPIRY: z.coerce.number().default(3600),
  API_PORT: z.coerce.number().default(3000),
  CORS_ORIGIN: z
    .string()
    .transform(v => v.split(',').map(s => s.trim()))
    .default('http://localhost:3000'),
  LOG_LEVEL: z.enum(['debug', 'info', 'warn', 'error']).default('info'),
  MAX_REQUEST_SIZE: z.string().transform(v => {
    const units: Record<string, number> = {
      B: 1,
      KB: 1024,
      MB: 1024 * 1024,
    };
    const match = v.match(/^(\d+)([A-Z]+)$/);
    if (!match) throw new Error(`Invalid size: ${v}`);
    return parseInt(match[1]) * (units[match[2]] || 1);
  }),
  ALLOWED_ORIGINS: z
    .string()
    .transform(v => v.split(',').map(s => s.trim()))
    .default('localhost:3000'),
});

// Validate at application startup
export const config = (() => {
  const result = envSchema.safeParse(process.env);

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

  return result.data;
})();

// Use validated config throughout app
export const db = createPool({
  connectionString: config.DATABASE_URL,
  max: config.NODE_ENV === 'production' ? 20 : 5,
});

Request/Response Validation Middleware

Centralize validation at API boundaries with reusable middleware.

import { z } from 'zod';
import { NextRequest, NextResponse } from 'next/server';

// Generic validation middleware factory
const withValidation = <T extends z.ZodType>(
  schema: T,
  location: 'body' | 'query' | 'params' = 'body'
) => {
  return async (
    request: NextRequest
  ): Promise<[valid: true, data: z.infer<T>] | [valid: false, errors: object]> => {
    try {
      let data: unknown;

      switch (location) {
        case 'body':
          data = await request.json();
          break;
        case 'query':
          data = Object.fromEntries(request.nextUrl.searchParams);
          break;
        case 'params':
          data = {};
          break;
      }

      const result = schema.safeParse(data);
      if (!result.success) {
        return [false, result.error.flatten()];
      }

      return [true, result.data];
    } catch (error) {
      return [false, { _error: 'Invalid request format' }];
    }
  };
};

// Route handler usage
const createPostSchema = z.object({
  title: z.string().min(5),
  content: z.string().min(10),
});

export async function POST(request: NextRequest) {
  const [valid, result] = await withValidation(createPostSchema)(request);

  if (!valid) {
    return NextResponse.json(
      { error: 'Invalid request', issues: result },
      { status: 400 }
    );
  }

  const post = await db.post.create({ data: result });
  return NextResponse.json(post, { status: 201 });
}

Zod Error Formatting for APIs

Format validation errors in a way clients can use to display field-level errors.

import { z } from 'zod';

const formatZodError = (error: z.ZodError) => {
  return {
    fieldErrors: error.flatten().fieldErrors,
    formErrors: error.flatten().formErrors,
    issues: error.issues.map(issue => ({
      path: issue.path.join('.'),
      message: issue.message,
      code: issue.code,
    })),
  };
};

// GraphQL-style error format
const formatForGraphQL = (error: z.ZodError) => {
  return {
    errors: error.issues.map(issue => ({
      message: issue.message,
      extensions: {
        code: 'BAD_USER_INPUT',
        field: issue.path[0],
        validation: issue.code,
      },
    })),
  };
};

// REST API error format
const formatForRest = (error: z.ZodError) => {
  const fieldErrors = error.flatten().fieldErrors;
  return {
    error: 'validation_error',
    details: Object.entries(fieldErrors).map(([field, messages]) => ({
      field,
      messages: Array.isArray(messages) ? messages : [messages],
    })),
  };
};

Validation Checklist

  • All external inputs validated at entry points (HTTP, events, files)
  • Schemas defined for request bodies, query parameters, environment variables
  • Type inference used (z.infer<typeof schema>) to keep types DRY
  • Error messages are user-friendly and actionable
  • Sensitive data not exposed in error responses
  • Transformations normalize input (trim, lowercase, coerce types)
  • Discriminated unions used for variant response types
  • Environment validation runs at startup
  • Custom refinements validate cross-field constraints

Conclusion

Zod bridges the gap between TypeScript's static guarantees and runtime reality. Define schemas once, generate types automatically, validate at every boundary, and transform data into normalized shapes. Use discriminated unions for type-safe API responses and environment validation to fail fast when configuration is incorrect.