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
- TypeScript Integration: Full type inference and type safety
- Declarative Syntax: Express validation rules, not validation logic
- Composable Schemas: Build complex validations from simple parts
- Custom Validation: Add business rules with
refine() - Transformation: Convert and sanitize data inline
- Better Maintainability: All validation in one place
- 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.