Zod Validation in Hono
Patterns for request validation using Zod and @hono/zod-validator.
Setup
bun add zod @hono/zod-validator
Basic Validation
JSON Body
import { zValidator } from '@hono/zod-validator'
import { z } from 'zod'
const createUserSchema = z.object({
email: z.string().email('Invalid email address'),
name: z.string().min(1, 'Name is required').max(100),
age: z.number().int().positive().optional(),
})
app.post('/users',
zValidator('json', createUserSchema),
async (c) => {
const data = c.req.valid('json')
// data is typed as { email: string; name: string; age?: number }
return c.json(data, 201)
}
)
Query Parameters
const paginationSchema = z.object({
page: z.coerce.number().int().positive().default(1),
limit: z.coerce.number().int().min(1).max(100).default(20),
sort: z.enum(['asc', 'desc']).default('desc'),
search: z.string().optional(),
})
app.get('/users',
zValidator('query', paginationSchema),
async (c) => {
const { page, limit, sort, search } = c.req.valid('query')
// All values are properly typed and coerced
return c.json({ page, limit, sort, search })
}
)
Path Parameters
const userParamsSchema = z.object({
id: z.string().uuid('Invalid user ID format'),
})
app.get('/users/:id',
zValidator('param', userParamsSchema),
async (c) => {
const { id } = c.req.valid('param')
return c.json({ id })
}
)
Headers
const authHeaderSchema = z.object({
authorization: z.string().startsWith('Bearer '),
'x-request-id': z.string().uuid().optional(),
})
app.get('/protected',
zValidator('header', authHeaderSchema),
async (c) => {
const headers = c.req.valid('header')
return c.json({ authenticated: true })
}
)
Form Data
const uploadSchema = z.object({
title: z.string().min(1),
description: z.string().optional(),
// File validation happens separately
})
app.post('/upload',
zValidator('form', uploadSchema),
async (c) => {
const { title, description } = c.req.valid('form')
return c.json({ title, description })
}
)
Schema Patterns
Reusable Field Schemas
// validators/common.ts
export const emailSchema = z.string().email('Invalid email')
export const uuidSchema = z.string().uuid('Invalid ID format')
export const dateSchema = z.coerce.date()
export const paginationSchema = z.object({
page: z.coerce.number().int().positive().default(1),
limit: z.coerce.number().int().min(1).max(100).default(20),
})
Create/Update Pattern
// validators/user.schema.ts
export const createUserSchema = z.object({
email: z.string().email(),
name: z.string().min(1).max(100),
password: z.string().min(8),
role: z.enum(['user', 'admin']).default('user'),
})
// Partial for updates (all fields optional)
export const updateUserSchema = createUserSchema.partial()
// Omit for specific updates
export const updatePasswordSchema = createUserSchema.pick({
password: true,
}).extend({
currentPassword: z.string(),
confirmPassword: z.string(),
}).refine(data => data.password === data.confirmPassword, {
message: 'Passwords do not match',
path: ['confirmPassword'],
})
// Infer TypeScript types
export type CreateUser = z.infer<typeof createUserSchema>
export type UpdateUser = z.infer<typeof updateUserSchema>
Nested Objects
const addressSchema = z.object({
street: z.string(),
city: z.string(),
country: z.string(),
zip: z.string(),
})
const orderSchema = z.object({
items: z.array(z.object({
productId: z.string().uuid(),
quantity: z.number().int().positive(),
})).min(1, 'At least one item required'),
shippingAddress: addressSchema,
billingAddress: addressSchema.optional(),
})
Conditional Validation
const paymentSchema = z.discriminatedUnion('method', [
z.object({
method: z.literal('card'),
cardNumber: z.string().length(16),
cvv: z.string().length(3),
}),
z.object({
method: z.literal('paypal'),
paypalEmail: z.string().email(),
}),
z.object({
method: z.literal('bank'),
accountNumber: z.string(),
routingNumber: z.string(),
}),
])
Custom Refinements
const registrationSchema = z.object({
password: z.string().min(8),
confirmPassword: z.string(),
}).refine(data => data.password === data.confirmPassword, {
message: 'Passwords must match',
path: ['confirmPassword'],
})
const dateRangeSchema = z.object({
startDate: z.coerce.date(),
endDate: z.coerce.date(),
}).refine(data => data.endDate > data.startDate, {
message: 'End date must be after start date',
path: ['endDate'],
})
Transform
const userInputSchema = z.object({
email: z.string().email().toLowerCase().trim(),
name: z.string().trim(),
tags: z.string().transform(s => s.split(',').map(t => t.trim())),
})
Custom Error Handling
Custom Error Hook
import { zValidator } from '@hono/zod-validator'
const customValidator = <T extends z.ZodType>(
target: 'json' | 'query' | 'param' | 'header' | 'form',
schema: T
) => {
return zValidator(target, schema, (result, c) => {
if (!result.success) {
const errors = result.error.issues.map(issue => ({
field: issue.path.join('.'),
message: issue.message,
}))
return c.json({
error: 'Validation failed',
details: errors,
}, 400)
}
})
}
// Usage
app.post('/users',
customValidator('json', createUserSchema),
async (c) => {
const data = c.req.valid('json')
return c.json(data)
}
)
Validation Error Response Format
// Standard error format
{
"error": "Validation failed",
"details": [
{ "field": "email", "message": "Invalid email address" },
{ "field": "name", "message": "Name is required" }
]
}
Multiple Validators
Chain validators for different request parts:
app.put('/users/:id',
zValidator('param', userParamsSchema),
zValidator('json', updateUserSchema),
async (c) => {
const { id } = c.req.valid('param')
const data = c.req.valid('json')
return c.json({ id, ...data })
}
)
Type Export Pattern
// validators/user.schema.ts
import { z } from 'zod'
export const createUserSchema = z.object({
email: z.string().email(),
name: z.string(),
})
export const updateUserSchema = createUserSchema.partial()
export const userParamsSchema = z.object({
id: z.string().uuid(),
})
export const userQuerySchema = z.object({
page: z.coerce.number().default(1),
limit: z.coerce.number().default(20),
})
// Export inferred types
export type CreateUser = z.infer<typeof createUserSchema>
export type UpdateUser = z.infer<typeof updateUserSchema>
export type UserParams = z.infer<typeof userParamsSchema>
export type UserQuery = z.infer<typeof userQuerySchema>
Best Practices
- Always use
zValidatorfor all request inputs - Use
z.coercefor query params (they're always strings) - Provide clear error messages in schema definitions
- Export inferred types for use elsewhere
- Create reusable field schemas for common patterns
- Use
.partial()for update schemas - Use
.refine()for cross-field validation - Set sensible defaults with
.default()