Request Validation with Zod and Hono
How to implement comprehensive request validation in Hono using Zod schemas, covering body parsing, query parameters, headers, and custom error formatting for API gateways.
Every request that enters an API gateway is untrusted. It might carry a malformed JSON body, missing required headers, invalid query parameters, or path parameters that do not match expected formats. Without validation, these malformed requests propagate to upstream services, where they cause cryptic errors, data corruption, or security vulnerabilities. Dispatch validates every request at the gateway layer before it touches any backend service. We use Zod for schema definition and Hono's validator middleware for integration, and this article shows how we put them together.
Why Validate at the Gateway
Validation at the gateway provides several advantages over validating in each backend service. First, it creates a single source of truth for API contracts. Instead of duplicating validation logic across five microservices, the gateway enforces the contract once. Second, it rejects malformed requests before they consume backend resources. A request with an invalid body never reaches the database layer, never triggers error handling in the service, and never generates confusing error logs. Third, it standardizes error responses. When validation fails at the gateway, every client receives the same error format regardless of which service the request was destined for.
// Without gateway validation: each service handles errors differently
// Service A: { error: "Invalid email" }
// Service B: { message: "Validation failed", details: [...] }
// Service C: 500 Internal Server Error (unhandled)
// With Dispatch validation: consistent error envelope
// { error: { code: 422, message: "Validation failed", details: [...], requestId: "..." } }Setting Up Zod with Hono
Hono provides a first-party Zod validator middleware through the @hono/zod-validator package. The integration is seamless: you define a Zod schema, pass it to the validator, and Hono ensures the request matches the schema before your handler executes.
import { Hono } from 'hono'
import { zValidator } from '@hono/zod-validator'
import { z } from 'zod'
const app = new Hono()
// Define the schema
const CreateTransferSchema = z.object({
sourceAccountId: z.string().uuid(),
destinationAccountId: z.string().uuid(),
amount: z.number().positive().max(1_000_000),
currency: z.enum(['USD', 'EUR', 'GBP', 'EGP']),
reference: z.string().min(1).max(255).optional(),
metadata: z.record(z.string()).optional(),
})
// Apply the validator
app.post(
'/api/v1/transfers',
zValidator('json', CreateTransferSchema),
async (c) => {
// TypeScript knows the exact shape of the validated body
const body = c.req.valid('json')
// body.sourceAccountId: string
// body.amount: number
// body.currency: 'USD' | 'EUR' | 'GBP' | 'EGP'
// body.reference: string | undefined
const result = await createTransfer(body)
return c.json(result, 201)
}
)The zValidator function accepts a target ('json', 'query', 'header', 'param', 'form', or 'cookie') and a Zod schema. It automatically parses the request data from the specified source and validates it against the schema. If validation fails, it returns a 400 response with error details.
Validating Different Request Parts
A complete API request has multiple parts that may need validation: the JSON body, query parameters, path parameters, and headers. Dispatch validates all of them:
import { z } from 'zod'
import { zValidator } from '@hono/zod-validator'
// Path parameters
const UserParamsSchema = z.object({
userId: z.string().uuid(),
})
// Query parameters (always strings, use coerce for type conversion)
const PaginationSchema = z.object({
page: z.coerce.number().int().positive().default(1),
limit: z.coerce.number().int().min(1).max(100).default(20),
sortBy: z.enum(['createdAt', 'updatedAt', 'name']).default('createdAt'),
sortOrder: z.enum(['asc', 'desc']).default('desc'),
search: z.string().max(200).optional(),
})
// Headers
const AuthHeaderSchema = z.object({
authorization: z
.string()
.regex(/^Bearer [A-Za-z0-9\-._~+/]+=*$/, 'Invalid bearer token format'),
'x-api-version': z.enum(['2024-01', '2024-06', '2025-01']).optional(),
'x-idempotency-key': z.string().uuid().optional(),
})
// Combine multiple validators on a single route
app.get(
'/api/v1/users/:userId/transactions',
zValidator('param', UserParamsSchema),
zValidator('query', PaginationSchema),
zValidator('header', AuthHeaderSchema),
async (c) => {
const { userId } = c.req.valid('param')
const { page, limit, sortBy, sortOrder, search } = c.req.valid('query')
const headers = c.req.valid('header')
// All inputs are validated and correctly typed
const transactions = await fetchTransactions({
userId,
page,
limit,
sortBy,
sortOrder,
search,
})
return c.json({ data: transactions, pagination: { page, limit } })
}
)The z.coerce variants are essential for query parameters. Query strings are always strings in HTTP, but your application logic needs them as numbers, booleans, or dates. Zod's coercion handles the conversion and validates the result in one step.
Custom Error Formatting
The default Zod error format is detailed but not always client-friendly. Dispatch uses a custom error hook to format validation errors into a consistent, actionable envelope:
import { zValidator } from '@hono/zod-validator'
import { z, ZodError } from 'zod'
import type { Context } from 'hono'
function formatZodError(error: ZodError): Array<{
field: string
message: string
code: string
}> {
return error.issues.map((issue) => ({
field: issue.path.join('.'),
message: issue.message,
code: issue.code,
}))
}
// Custom validator wrapper with standardized error responses
function validate<T extends z.ZodType>(
target: 'json' | 'query' | 'param' | 'header',
schema: T
) {
return zValidator(target, schema, (result, c: Context) => {
if (!result.success) {
const requestId = c.get('requestId') || 'unknown'
return c.json(
{
error: {
code: 422,
message: 'Validation failed',
requestId,
details: formatZodError(result.error),
},
},
422
)
}
})
}
// Usage: cleaner than raw zValidator calls
app.post(
'/api/v1/users',
validate('json', z.object({
email: z.string().email(),
name: z.string().min(1).max(100),
phone: z.string().regex(/^\+[1-9]\d{6,14}$/, 'Must be E.164 format'),
})),
async (c) => {
const body = c.req.valid('json')
// ...
}
)When a client sends an invalid request, they receive a response like:
{
"error": {
"code": 422,
"message": "Validation failed",
"requestId": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
"details": [
{
"field": "email",
"message": "Invalid email",
"code": "invalid_string"
},
{
"field": "phone",
"message": "Must be E.164 format",
"code": "invalid_string"
}
]
}
}This format tells the client exactly which fields are invalid and why, making it straightforward to display field-level errors in a form UI.
Advanced Schema Patterns
Real-world API validation goes beyond basic type checking. Dispatch uses several advanced Zod patterns for complex validation scenarios.
Discriminated unions handle polymorphic request bodies where the shape depends on a type field:
const PaymentMethodSchema = z.discriminatedUnion('type', [
z.object({
type: z.literal('card'),
cardNumber: z.string().regex(/^\d{16}$/),
expiryMonth: z.number().int().min(1).max(12),
expiryYear: z.number().int().min(2025).max(2035),
cvv: z.string().regex(/^\d{3,4}$/),
}),
z.object({
type: z.literal('bank_transfer'),
bankCode: z.string().length(11), // SWIFT code
accountNumber: z.string().min(8).max(34), // IBAN
}),
z.object({
type: z.literal('wallet'),
walletId: z.string().uuid(),
provider: z.enum(['apple_pay', 'google_pay']),
}),
])
app.post(
'/api/v1/payments',
validate('json', z.object({
amount: z.number().positive(),
currency: z.enum(['USD', 'EUR', 'EGP']),
paymentMethod: PaymentMethodSchema,
})),
async (c) => {
const body = c.req.valid('json')
// TypeScript narrows the type based on the discriminant
if (body.paymentMethod.type === 'card') {
// body.paymentMethod.cardNumber is accessible
}
return c.json({ status: 'processing' })
}
)Refinements add custom validation logic that goes beyond structural type checking:
const TransferSchema = z
.object({
sourceAccountId: z.string().uuid(),
destinationAccountId: z.string().uuid(),
amount: z.number().positive(),
scheduledAt: z.string().datetime().optional(),
})
.refine(
(data) => data.sourceAccountId !== data.destinationAccountId,
{
message: 'Source and destination accounts must be different',
path: ['destinationAccountId'],
}
)
.refine(
(data) => {
if (data.scheduledAt) {
return new Date(data.scheduledAt) > new Date()
}
return true
},
{
message: 'Scheduled time must be in the future',
path: ['scheduledAt'],
}
)Transforms convert validated input into a different shape, useful for normalizing client data before forwarding to upstream services:
const SearchQuerySchema = z.object({
q: z.string().transform((s) => s.trim().toLowerCase()),
tags: z
.string()
.optional()
.transform((s) => (s ? s.split(',').map((t) => t.trim()) : [])),
dateFrom: z
.string()
.optional()
.transform((s) => (s ? new Date(s) : undefined)),
dateTo: z
.string()
.optional()
.transform((s) => (s ? new Date(s) : undefined)),
})
app.get(
'/api/v1/search',
validate('query', SearchQuerySchema),
async (c) => {
const query = c.req.valid('query')
// query.q is trimmed and lowercased
// query.tags is string[] (parsed from comma-separated string)
// query.dateFrom is Date | undefined
return c.json({ results: await search(query) })
}
)Schema Reuse and Composition
In a gateway handling dozens of services, schema duplication is a real risk. Dispatch maintains a shared schema library that defines common patterns used across multiple endpoints:
// schemas/common.ts
import { z } from 'zod'
export const UUIDParam = z.string().uuid()
export const PaginationQuery = z.object({
page: z.coerce.number().int().positive().default(1),
limit: z.coerce.number().int().min(1).max(100).default(20),
})
export const MoneySchema = z.object({
amount: z.number().positive().multipleOf(0.01),
currency: z.enum(['USD', 'EUR', 'GBP', 'EGP']),
})
export const AddressSchema = z.object({
line1: z.string().min(1).max(200),
line2: z.string().max(200).optional(),
city: z.string().min(1).max(100),
state: z.string().max(100).optional(),
postalCode: z.string().min(3).max(20),
country: z.string().length(2), // ISO 3166-1 alpha-2
})
export const TimestampFields = z.object({
createdAt: z.string().datetime(),
updatedAt: z.string().datetime(),
})
// Compose into service-specific schemas
export const CreateInvoiceSchema = z.object({
customerId: UUIDParam,
lineItems: z.array(
z.object({
description: z.string().min(1).max(500),
quantity: z.number().int().positive(),
unitPrice: MoneySchema,
})
).min(1).max(50),
billingAddress: AddressSchema,
dueDate: z.string().datetime(),
notes: z.string().max(2000).optional(),
})This shared library ensures that a UUID is validated the same way across every endpoint, money amounts always require two decimal places, and addresses always follow the same structure. When a validation rule changes -- say the maximum postal code length increases -- the change propagates to every endpoint that uses the shared schema.
Performance Considerations
Zod schema validation is not free. For high-throughput gateways, the CPU cost of validation matters. Dispatch applies several optimizations.
First, we pre-compile schemas by creating them at module scope rather than inside request handlers. Zod performs internal optimization when a schema object is created, and creating it once amortizes that cost.
Second, we use z.lazy() sparingly. Recursive schemas (like nested comment threads) require lazy evaluation, which prevents Zod from optimizing the schema structure. Where possible, we limit recursion depth with explicit nesting.
Third, for extremely hot paths, we use Zod's .safeParse() method and cache the result on the context object, avoiding redundant validation if multiple middleware layers need the same validated data:
app.use('/api/v1/*', async (c, next) => {
if (c.req.method === 'POST' || c.req.method === 'PUT') {
try {
const rawBody = await c.req.json()
c.set('rawBody', rawBody) // Cache parsed body
} catch {
return c.json({ error: { code: 400, message: 'Invalid JSON' } }, 400)
}
}
await next()
})Conclusion
Request validation is the gateway's first line of defense. By validating at the edge with Zod and Hono, Dispatch ensures that only well-formed, correctly typed requests reach backend services. The combination of Zod's expressive schema language, Hono's validator middleware, and TypeScript's type inference creates a validation layer that is both powerful and ergonomic. The schemas serve as living documentation of your API contract, the type inference eliminates handler bugs, and the standardized error format makes life easier for every client developer. Invest in your validation layer early -- it is far cheaper to reject a bad request at the gateway than to debug its effects three services downstream.
Related Articles
API Monitoring and Alerting Best Practices
A comprehensive guide to monitoring API gateways in production, covering the four golden signals, structured logging, distributed tracing, and actionable alerting strategies.
Edge Computing for Fintech: Latency and Compliance Benefits
How edge computing addresses the unique challenges of fintech platforms, including latency-sensitive transactions, data residency requirements, and regulatory compliance across jurisdictions.
API Performance Optimization: From 200ms to 20ms
A practical guide to optimizing API gateway performance, covering the specific techniques that took Dispatch's p95 latency from 200ms to under 20ms.