troubleshooting2025-02-01·11·213/348

Debugging Zod Schema Validation Errors in TypeScript

Common Zod validation patterns, custom validators, error message customization, and integration with API frameworks for runtime type safety.

Introduction

Zod has become the standard for runtime type validation in TypeScript projects. Its inferred types bridge the gap between static TypeScript checking and runtime data validation. However, crafting schemas that produce clear error messages and handle complex validation logic requires understanding Zod's internal mechanics.

After integrating Zod into multiple API projects, I have identified the most common schema design mistakes and the patterns that produce better developer and user experiences. This post covers the practical aspects of Zod validation that the official documentation does not emphasize.

Environment

node --version
# v20.11.0

npm list zod
# zod@3.22.4

npm list typescript
# typescript@5.3.3

The project uses Zod for:

  1. API request/response validation
  2. Environment variable validation
  3. Database query parameter validation
  4. Form input validation on the client side

Problem

The first issue was that the error messages produced by Zod were too technical for end users. When a user submitted a form with an invalid email, the error message read:

Invalid input

This provided no guidance on what was expected. The default Zod error messages are designed for developers, not end users.

The second issue was that complex schemas with .refine() produced validation errors that were difficult to trace. When a refinement failed, the error path pointed to the root of the schema instead of the specific field that caused the failure:

const schema = z.object({
  password: z.string().min(8),
  confirmPassword: z.string(),
}).refine((data) => data.password === data.confirmPassword, {
  message: "Passwords don't match",
  path: ["confirmPassword"],
});

// The error path was [""] instead of ["confirmPassword"]
// when the refinement condition was inverted

The third issue was that Zod's z.infer type did not include transformations applied by .transform(), causing type mismatches between the input and output types of a schema:

const schema = z.object({
  birthDate: z.string().transform((val) => new Date(val)),
});

type Input = z.infer;
// { birthDate: string }

type Output = z.infer;
// { birthDate: Date }  <- This is actually correct in Zod 3.22+
// But older versions required z.output for transformed types

Analysis

Zod validates data in two phases: parsing and refinement. Parsing checks the basic type constraints (string, number, required), while refinement adds custom validation logic. Error messages are generated differently for each phase, and understanding this distinction is key to producing useful errors.

The default error message template uses Zod's internal ErrorMap, which generates messages based on the validation rule that failed. To customize these messages, you must provide custom error maps at the schema level or use the .message() method on individual validations.

The .refine() path issue occurs because refinement errors are attached to the schema root by default. The path option in the refinement config should fix this, but there is a subtle behavior where multiple refinements can override each other's paths.

Solution

Define a custom error map for user-friendly messages:

import { ZodError, ZodIssue } from "zod";

const userFriendlyErrorMap: Record = {
  required_error: "This field is required",
  invalid_type_error: "Invalid value provided",
  too_small: "Value is too small",
  too_big: "Value is too large",
  invalid_string: "Invalid format",
};

const customErrorMap = (issue: ZodIssue, ctx: { defaultError: string }) => {
  if (issue.code === "invalid_type") {
    if (issue.received === "undefined" || issue.received === "null") {
      return { message: "This field is required" };
    }
    return { message: `Expected ${issue.expected}, received ${issue.received}` };
  }

  if (issue.code === "too_small") {
    if (issue.type === "string") {
      return { message: `Must be at least ${issue.minimum} characters` };
    }
    return { message: `Must be at least ${issue.minimum}` };
  }

  if (issue.code === "invalid_string") {
    if (issue.validation === "email") {
      return { message: "Please enter a valid email address" };
    }
    if (issue.validation === "url") {
      return { message: "Please enter a valid URL" };
    }
  }

  return { message: ctx.defaultError };
};

// Apply to a specific schema
const emailSchema = z.string().email().errorMap(customErrorMap);

Create reusable validation patterns with .superRefine():

import { z } from "zod";

// SuperRefine provides better error paths than .refine()
const changePasswordSchema = z
  .object({
    currentPassword: z.string().min(1, "Current password is required"),
    newPassword: z
      .string()
      .min(8, "Password must be at least 8 characters")
      .max(100, "Password must be less than 100 characters")
      .regex(/[A-Z]/, "Password must contain at least one uppercase letter")
      .regex(/[0-9]/, "Password must contain at least one number"),
    confirmPassword: z.string(),
  })
  .superRefine((data, ctx) => {
    // Check current password matches
    if (data.currentPassword === data.newPassword) {
      ctx.addIssue({
        code: z.ZodIssueCode.custom,
        message: "New password must be different from current password",
        path: ["newPassword"],
      });
    }

    // Check passwords match
    if (data.newPassword !== data.confirmPassword) {
      ctx.addIssue({
        code: z.ZodIssueCode.custom,
        message: "Passwords do not match",
        path: ["confirmPassword"],
      });
    }
  });

// Type inference works correctly
type ChangePasswordInput = z.infer;

Build environment variable schemas with proper defaults:

// src/env.ts
import { z } from "zod";

const envSchema = z.object({
  NODE_ENV: z.enum(["development", "production", "test"]),
  DATABASE_URL: z.string().url(),
  REDIS_URL: z.string().url().optional(),
  PORT: z.coerce.number().int().min(1).max(65535).default(3000),
  JWT_SECRET: z.string().min(32, "JWT_SECRET must be at least 32 characters"),
  CORS_ORIGINS: z
    .string()
    .transform((val) => val.split(","))
    .pipe(z.array(z.string().url())),
  LOG_LEVEL: z.enum(["debug", "info", "warn", "error"]).default("info"),
});

// Validate once at startup
const parsed = envSchema.safeParse(process.env);

if (!parsed.success) {
  console.error(
    "Invalid environment variables:",
    parsed.error.flatten().fieldErrors
  );
  process.exit(1);
}

export const env = parsed.data;

Format errors for API responses:

// src/middleware/validate.ts
import { z, ZodSchema, ZodError } from "zod";
import { Request, Response, NextFunction } from "express";

export function validate(schema: ZodSchema) {
  return (req: Request, res: Response, next: NextFunction) => {
    try {
      req.body = schema.parse(req.body);
      next();
    } catch (error) {
      if (error instanceof ZodError) {
        const formattedErrors = error.errors.map((err) => ({
          field: err.path.join("."),
          message: err.message,
          code: err.code,
        }));

        return res.status(400).json({
          error: "Validation failed",
          details: formattedErrors,
        });
      }
      next(error);
    }
  };
}

// Usage in routes
const CreateUserSchema = z.object({
  name: z.string().min(1).max(100),
  email: z.string().email(),
  age: z.number().int().min(13).max(150).optional(),
});

app.post("/users", validate(CreateUserSchema), async (req, res) => {
  const user = await createUser(req.body);
  res.status(201).json(user);
});

Create discriminated unions for complex form validation:

// Payment form with different types
const paymentSchema = z.discriminatedUnion("type", [
  z.object({
    type: z.literal("credit_card"),
    cardNumber: z.string().regex(/^\d{16}$/, "Must be 16 digits"),
    expiry: z.string().regex(/^\d{2}\/\d{2}$/, "Format: MM/YY"),
    cvv: z.string().regex(/^\d{3,4}$/, "Must be 3 or 4 digits"),
  }),
  z.object({
    type: z.literal("paypal"),
    email: z.string().email(),
  }),
  z.object({
    type: z.literal("bank_transfer"),
    accountNumber: z.string().min(8),
    routingNumber: z.string().length(9),
  }),
]);

type PaymentInput = z.infer;
// { type: "credit_card"; cardNumber: string; expiry: string; cvv: string }
// | { type: "paypal"; email: string }
// | { type: "bank_transfer"; accountNumber: string; routingNumber: string }

Lessons Learned

  1. Use .superRefine() instead of .refine() for complex validations that need to set specific error paths or add multiple issues.

  2. Create a global error map at the application root for consistent error messages across all schemas.

  3. Always validate environment variables at startup using z.safeParse() with process.exit(1) on failure. This prevents silent configuration errors.

  4. Coerce types with z.coerce when dealing with query parameters or form data that arrives as strings but should be numbers or booleans.

  5. Zod schemas are composable. Build complex schemas by composing simpler ones with .merge(), .extend(), or .pick() to maintain consistency.

This blog does not accept any external sponsorships, affiliate marketing, or ad revenue.