Introduction

Input validation is essential for any API, but it often turns into a tangled mess of if-statements and conditional logic. In this guide, I'll show you how Zod, a TypeScript-first validation library, can transform cluttered validation code into clean, readable schemas. We'll refactor a real-world example from messy spaghetti code to elegant, maintainable validation logic.

The Problem: Validation Spaghetti

Let's start with a typical scenario. I have a basic Hono app with a /books API endpoint that returns an array of books. Users can optionally filter by price using minPrice and maxPrice query parameters.

The API in Action

The endpoint works as expected:

  • GET /books — Returns all books
  • GET /books?minPrice=10&maxPrice=50 — Returns books within the price range
  • GET /books?minPrice=none — Returns error: "Must be a number between 0 and 1000"
  • GET /books?invalidParam=true — Returns error: "Only minPrice and maxPrice are valid"

Everything functions correctly, and all test cases pass. But here's the problem:

The Ugly Truth

Looking at the code, lines 8-57 are pure validation logic—that's nearly 50 lines cluttering up a simple function! The code contains:

  • Five different if-statements
  • Complex conditional logic
  • Poor readability
  • Maintenance nightmares waiting to happen
// The spaghetti code (simplified example)
app.get('/books', (c) => {
  const minPrice = c.req.query('minPrice');
  const maxPrice = c.req.query('maxPrice');

  // Validation nightmare begins...
  if (minPrice && isNaN(Number(minPrice))) {
    return c.json({ error: 'minPrice must be a number' }, 400);
  }

  if (maxPrice && isNaN(Number(maxPrice))) {
    return c.json({ error: 'maxPrice must be a number' }, 400);
  }

  if (minPrice && (Number(minPrice) < 0 || Number(minPrice) > 1000)) {
    return c.json({ error: 'minPrice must be between 0 and 1000' }, 400);
  }

  if (maxPrice && (Number(maxPrice) < 0 || Number(maxPrice) > 1000)) {
    return c.json({ error: 'maxPrice must be between 0 and 1000' }, 400);
  }

  if (minPrice && maxPrice && Number(minPrice) > Number(maxPrice)) {
    return c.json({ error: 'minPrice must be less than maxPrice' }, 400);
  }

  // Check for invalid query params
  const validParams = ['minPrice', 'maxPrice'];
  const receivedParams = Object.keys(c.req.query());
  for (const param of receivedParams) {
    if (!validParams.includes(param)) {
      return c.json({ error: 'Only minPrice and maxPrice are valid' }, 400);
    }
  }

  // Finally... the actual business logic
  const books = getBooksByPrice(Number(minPrice), Number(maxPrice));
  return c.json(books);
});

This is a maintenance nightmare. Let's fix it with Zod.

The Solution: Enter Zod

Zod allows us to implement the exact same validation logic in a cleaner, more readable way. Let's refactor step by step.

Step 1: Basic Zod Setup

First, we'll use the Zod validator middleware to handle query parameters:

import { zValidator } from '@hono/zod-validator';
import { z } from 'zod';

app.get('/books',
  zValidator('query', z.object({
    minPrice: z.string().optional().default('0'),
    maxPrice: z.string().optional().default('1000')
  })),
  (c) => {
    const { minPrice, maxPrice } = c.req.valid('query');
    const books = getBooksByPrice(minPrice, maxPrice);
    return c.json(books);
  }
);

Already cleaner! But we have a TypeScript error—getBooksByPrice expects numbers, not strings.

Step 2: Transform String to Number

Zod integrates beautifully with TypeScript. We can use transform() to convert strings to numbers:

const querySchema = z.object({
  minPrice: z.string()
    .optional()
    .default('0')
    .transform(val => Number(val)),
  maxPrice: z.string()
    .optional()
    .default('1000')
    .transform(val => Number(val))
});

TypeScript error gone! Now let's handle our business rules.

Step 3: Custom Validation with Refine

We need to ensure minPrice is less than maxPrice. Zod's refine() function lets us add custom logic:

const querySchema = z.object({
  minPrice: z.string().optional().default('0').transform(Number),
  maxPrice: z.string().optional().default('1000').transform(Number)
}).refine(
  (data) => data.minPrice < data.maxPrice,
  { message: 'minPrice must be less than maxPrice' }
);

Step 4: Handling Error Formatting

Running our tests reveals that Zod returns errors in a specific format:

{
  "issues": [
    {
      "message": "minPrice must be less than maxPrice",
      "path": ["minPrice"]
    }
  ]
}

We want to return just the error message. The zValidator middleware accepts a third parameter—a hook that lets us format errors:

app.get('/books',
  zValidator('query', querySchema, (result, c) => {
    if (!result.success) {
      return c.json(
        { error: result.error.issues[0].message },
        400
      );
    }
  }),
  (c) => {
    const { minPrice, maxPrice } = c.req.valid('query');
    const books = getBooksByPrice(minPrice, maxPrice);
    return c.json(books);
  }
);

Step 5: Rejecting Invalid Parameters

We need to reject any query parameters that aren't minPrice or maxPrice. Zod's strict() method handles this elegantly:

const querySchema = z.object({
  minPrice: z.string().optional().default('0').transform(Number),
  maxPrice: z.string().optional().default('1000').transform(Number)
})
  .strict({ message: 'Only minPrice and maxPrice are valid query parameters' })
  .refine(
    (data) => data.minPrice < data.maxPrice,
    { message: 'minPrice must be less than maxPrice' }
  );

Step 6: Range Validation

Finally, we need to ensure prices are between 0 and 1000:

const querySchema = z.object({
  minPrice: z.string()
    .optional()
    .default('0')
    .transform(Number)
    .refine(
      (val) => val >= 0 && val <= 1000,
      { message: 'minPrice must be between 0 and 1000' }
    ),
  maxPrice: z.string()
    .optional()
    .default('1000')
    .transform(Number)
    .refine(
      (val) => val >= 0 && val <= 1000,
      { message: 'maxPrice must be between 0 and 1000' }
    )
})
  .strict({ message: 'Only minPrice and maxPrice are valid query parameters' })
  .refine(
    (data) => data.minPrice < data.maxPrice,
    { message: 'minPrice must be less than maxPrice' }
  );

All tests pass! We've reached feature parity with our spaghetti code.

Step 7: Final Refactor for Readability

Let's move everything to a separate schema file for better organization:

schemas/bookSchema.ts:

import { z } from 'zod';
import { zValidator } from '@hono/zod-validator';

export const querySchema = z.object({
  minPrice: z.string()
    .optional()
    .default('0')
    .transform(Number)
    .refine(
      (val) => val >= 0 && val <= 1000,
      { message: 'minPrice must be between 0 and 1000' }
    ),
  maxPrice: z.string()
    .optional()
    .default('1000')
    .transform(Number)
    .refine(
      (val) => val >= 0 && val <= 1000,
      { message: 'maxPrice must be between 0 and 1000' }
    )
})
  .strict({ message: 'Only minPrice and maxPrice are valid query parameters' })
  .refine(
    (data) => data.minPrice < data.maxPrice,
    { message: 'minPrice must be less than maxPrice' }
  );

export const bookValidator = zValidator('query', querySchema, (result, c) => {
  if (!result.success) {
    return c.json(
      { error: result.error.issues[0].message },
      400
    );
  }
});

index.ts:

import { bookValidator } from './schemas/bookSchema';

app.get('/books', bookValidator, (c) => {
  const { minPrice, maxPrice } = c.req.valid('query');
  const books = getBooksByPrice(minPrice, maxPrice);
  return c.json(books);
});

The Final Result

Let's compare our before and after:

Before: 50+ Lines of Spaghetti

  • Five if-statements
  • Nested conditionals
  • Manual type checking
  • Repetitive error handling
  • Business logic buried in validation

After: Clean and Declarative

  • All validation in one schema
  • No if-statements in the endpoint
  • Type-safe transformations
  • Consistent error handling
  • Business logic clearly separated

Our endpoint is now clean and easy to read. The Zod schema is simple and declarative, expressing what we want rather than how to check it.

Key Benefits of Zod

  1. TypeScript Integration: Full type inference and type safety
  2. Declarative Syntax: Express validation rules, not validation logic
  3. Composable Schemas: Build complex validations from simple parts
  4. Custom Validation: Add business rules with refine()
  5. Transformation: Convert and sanitize data inline
  6. Better Maintainability: All validation in one place
  7. Improved Readability: Self-documenting validation rules

When to Use Zod

Zod shines in scenarios where you need:

  • API endpoint validation
  • Form data validation
  • Configuration file parsing
  • External data validation
  • Type-safe environment variables
  • Runtime type checking

Conclusion

Validation doesn't have to be a mess of if-statements and error handling. Zod transforms cluttered validation logic into clean, maintainable schemas that are easy to read, test, and extend.

By moving from imperative validation (checking conditions one by one) to declarative schemas (describing what valid data looks like), you'll write less code, catch more bugs, and spend less time debugging validation issues.

Your future self—and your teammates—will thank you for choosing clarity over chaos.