Hono Validation Patterns Overview
Hono provides a lightweight built-in validator and integrates seamlessly with popular validation libraries like Zod, TypeBox, and Valibot. Validation happens as middleware, providing type-safe access to validated data in handlers.
Key Features:
Built-in lightweight validator First-class Zod integration via @hono/zod-validator Standard Schema support (works with any validation library) Type inference from validation schemas Validates: JSON, forms, query params, headers, cookies, path params When to Use This Skill
Use Hono validation when:
Validating API request bodies (JSON, form data) Ensuring query parameters meet requirements Validating authentication headers Type-safe path parameter parsing Cookie validation Installation
Zod (recommended)
npm install @hono/zod-validator zod
TypeBox
npm install @hono/typebox-validator @sinclair/typebox
Valibot
npm install @hono/valibot-validator valibot
Standard Schema (any compatible library)
npm install @hono/standard-validator
Zod Validation (Recommended) Basic Usage import { Hono } from 'hono' import { zValidator } from '@hono/zod-validator' import { z } from 'zod'
const app = new Hono()
// Define schema const createUserSchema = z.object({ name: z.string().min(1).max(100), email: z.string().email(), age: z.number().int().min(0).max(150).optional() })
// Apply validation app.post( '/users', zValidator('json', createUserSchema), (c) => { // Fully typed! { name: string; email: string; age?: number } const data = c.req.valid('json') return c.json({ user: data }, 201) } )
Validation Targets // JSON body app.post('/api', zValidator('json', schema), handler)
// Form data (multipart or urlencoded) app.post('/form', zValidator('form', schema), handler)
// Query parameters app.get('/search', zValidator('query', z.object({ q: z.string(), page: z.coerce.number().default(1), limit: z.coerce.number().max(100).default(20) })), handler)
// Path parameters app.get('/users/:id', zValidator('param', z.object({ id: z.string().uuid() })), handler)
// Headers (use lowercase!) app.post('/api', zValidator('header', z.object({ 'authorization': z.string().startsWith('Bearer '), 'x-request-id': z.string().uuid().optional() })), handler)
// Cookies app.get('/dashboard', zValidator('cookie', z.object({ session: z.string().min(1) })), handler)
Custom Error Handling import { zValidator } from '@hono/zod-validator' import { z } from 'zod'
// Custom error response app.post( '/users', zValidator('json', createUserSchema, (result, c) => { if (!result.success) { return c.json({ error: 'Validation failed', details: result.error.flatten() }, 400) } }), (c) => { const data = c.req.valid('json') return c.json({ user: data }, 201) } )
Multiple Validators const paramsSchema = z.object({ userId: z.string().uuid() })
const bodySchema = z.object({ name: z.string().optional(), email: z.string().email().optional() })
const querySchema = z.object({ fields: z.string().optional() })
app.patch( '/users/:userId', zValidator('param', paramsSchema), zValidator('json', bodySchema), zValidator('query', querySchema), (c) => { const { userId } = c.req.valid('param') const body = c.req.valid('json') const { fields } = c.req.valid('query')
return c.json({ updated: { userId, ...body } })
} )
Common Zod Patterns Coercion for Query/Form Data // Query params come as strings - use coerce const paginationSchema = z.object({ page: z.coerce.number().int().min(1).default(1), limit: z.coerce.number().int().min(1).max(100).default(20), sort: z.enum(['asc', 'desc']).default('desc') })
app.get('/items', zValidator('query', paginationSchema), (c) => { const { page, limit, sort } = c.req.valid('query') // page: number, limit: number, sort: 'asc' | 'desc' })
Optional with Defaults const configSchema = z.object({ theme: z.enum(['light', 'dark']).default('light'), notifications: z.boolean().default(true), language: z.string().default('en') })
Transformations const userSchema = z.object({ email: z.string().email().toLowerCase(), name: z.string().trim(), tags: z.string().transform(s => s.split(',')), // "a,b,c" → ["a","b","c"] createdAt: z.string().transform(s => new Date(s)) })
Refinements const passwordSchema = z.object({ password: z.string().min(8), confirmPassword: z.string() }).refine(data => data.password === data.confirmPassword, { message: "Passwords don't 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' })
Discriminated Unions const eventSchema = z.discriminatedUnion('type', [ z.object({ type: z.literal('click'), x: z.number(), y: z.number() }), z.object({ type: z.literal('scroll'), direction: z.enum(['up', 'down']) }), z.object({ type: z.literal('keypress'), key: z.string() }) ])
app.post('/events', zValidator('json', eventSchema), (c) => { const event = c.req.valid('json')
if (event.type === 'click') { console.log(event.x, event.y) // Typed correctly! } })
Built-in Validator
For simple cases without external dependencies:
import { Hono } from 'hono' import { validator } from 'hono/validator'
const app = new Hono()
app.post( '/posts', validator('json', (value, c) => { const { title, body } = value
if (!title || typeof title !== 'string') {
return c.json({ error: 'Title is required' }, 400)
}
if (!body || typeof body !== 'string') {
return c.json({ error: 'Body is required' }, 400)
}
// Return validated data (shapes the type)
return { title, body }
}), (c) => { // data is typed as { title: string; body: string } const data = c.req.valid('json') return c.json({ post: data }, 201) } )
TypeBox Validation import { tbValidator } from '@hono/typebox-validator' import { Type } from '@sinclair/typebox'
const UserSchema = Type.Object({ name: Type.String({ minLength: 1 }), email: Type.String({ format: 'email' }), age: Type.Optional(Type.Integer({ minimum: 0 })) })
app.post('/users', tbValidator('json', UserSchema), (c) => { const user = c.req.valid('json') return c.json({ user }, 201) })
Valibot Validation import { vValidator } from '@hono/valibot-validator' import * as v from 'valibot'
const UserSchema = v.object({ name: v.string([v.minLength(1)]), email: v.string([v.email()]), age: v.optional(v.number([v.integer(), v.minValue(0)])) })
app.post('/users', vValidator('json', UserSchema), (c) => { const user = c.req.valid('json') return c.json({ user }, 201) })
Standard Schema Validator
Works with any validation library implementing the Standard Schema spec:
import { standardValidator } from '@hono/standard-validator' import { z } from 'zod'
// Works with Zod, Valibot, ArkType, etc. app.post('/users', standardValidator('json', z.object({ name: z.string(), email: z.string().email() })), handler)
File Upload Validation import { zValidator } from '@hono/zod-validator' import { z } from 'zod'
const uploadSchema = z.object({ file: z.instanceof(File).refine( (file) => file.size <= 5 * 1024 * 1024, 'File must be less than 5MB' ).refine( (file) => ['image/jpeg', 'image/png'].includes(file.type), 'Only JPEG and PNG allowed' ), description: z.string().optional() })
app.post('/upload', zValidator('form', uploadSchema), async (c) => { const { file, description } = c.req.valid('form')
const buffer = await file.arrayBuffer() // Process file...
return c.json({ filename: file.name, size: file.size }) })
Reusable Schema Patterns Create Schema Factory // schemas/common.ts import { z } from 'zod'
export const paginationSchema = z.object({ page: z.coerce.number().int().min(1).default(1), limit: z.coerce.number().int().min(1).max(100).default(20) })
export const idParamSchema = z.object({ id: z.string().uuid() })
export const timestampSchema = z.object({ createdAt: z.string().datetime(), updatedAt: z.string().datetime() })
// Usage app.get('/items/:id', zValidator('param', idParamSchema), zValidator('query', paginationSchema), handler )
Extend Schemas const baseUserSchema = z.object({ name: z.string().min(1), email: z.string().email() })
const createUserSchema = baseUserSchema.extend({ password: z.string().min(8) })
const updateUserSchema = baseUserSchema.partial()
const userResponseSchema = baseUserSchema.extend({ id: z.string().uuid(), createdAt: z.string().datetime() })
Best Practices 1. Validate Early // CORRECT: Validation before any processing app.post('/users', zValidator('json', createUserSchema), // Validate first async (c) => { const data = c.req.valid('json') // Safe to use return c.json({ user: data }) } )
- Use Appropriate Targets // JSON for API bodies zValidator('json', schema)
// Form for HTML forms zValidator('form', schema)
// Query for URL parameters (remember coercion!) zValidator('query', z.object({ page: z.coerce.number() }))
// Param for route parameters zValidator('param', z.object({ id: z.string() }))
- Content-Type Matters // JSON validation requires Content-Type: application/json // Form validation requires Content-Type: application/x-www-form-urlencoded // or Content-Type: multipart/form-data
// Handle both: const schema = z.object({ name: z.string() })
app.post('/data', async (c, next) => { const contentType = c.req.header('content-type') if (contentType?.includes('application/json')) { return zValidator('json', schema)(c, next) } else { return zValidator('form', schema)(c, next) } }, handler )
- Lowercase Headers // Headers must be lowercase in validation zValidator('header', z.object({ 'authorization': z.string(), // ✓ lowercase 'x-custom-header': z.string(), // ✓ lowercase // 'Authorization': z.string(), // ✗ won't work }))
Error Response Format Zod Flatten Format app.post('/users', zValidator('json', schema, (result, c) => { if (!result.success) { return c.json({ success: false, error: result.error.flatten() }, 400) } }), handler)
// Response: { "success": false, "error": { "formErrors": [], "fieldErrors": { "email": ["Invalid email address"], "age": ["Number must be greater than 0"] } } }
Zod Issues Format app.post('/users', zValidator('json', schema, (result, c) => { if (!result.success) { return c.json({ success: false, errors: result.error.issues.map(issue => ({ field: issue.path.join('.'), message: issue.message })) }, 400) } }), handler)
// Response: { "success": false, "errors": [ { "field": "email", "message": "Invalid email address" }, { "field": "age", "message": "Number must be greater than 0" } ] }
Quick Reference Zod Validator Targets Target Use Case Example json JSON body zValidator('json', schema) form Form data zValidator('form', schema) query URL query params zValidator('query', schema) param Route params zValidator('param', schema) header Request headers zValidator('header', schema) cookie Cookies zValidator('cookie', schema) Common Zod Types z.string() // String z.number() // Number z.boolean() // Boolean z.date() // Date z.enum(['a', 'b']) // Enum z.array(z.string()) // Array z.object({}) // Object z.optional(z.string()) // Optional z.nullable(z.string()) // Nullable z.coerce.number() // Coerce to number z.string().default('val') // With default
Related Skills hono-core - Framework fundamentals hono-rpc - Type-safe RPC with validation typescript-core - TypeScript patterns
Version: Hono 4.x, @hono/zod-validator 0.2.x Last Updated: January 2025 License: MIT