Hono: Validation

Somebody who likes to code
Validation is a critical aspect of building robust APIs. It ensures that incoming data meets expected criteria before processing, preventing bugs, security vulnerabilities, and unexpected behavior. In this article, we'll explore how to implement validation in the application built in our previous post using Zod.
Hono Middlewares
Before diving into validation, it's essential to understand how Hono's middleware system works, as validators are implemented as middleware. Middleware functions are handlers that execute during the request-response cycle.
What is Hono Middleware?
A middleware is a function that accepts two parameters:
Context (
c) - Provides access to request data, environment bindings, and response utilities.Next (
next) - An async function that executes the next middleware in the chain.
The middleware handler interface is defined as:
type MiddlewareHandler = (c: Context, next: Next) => Promise<Response | void>
How does Middleware Work?
The execution flow is:
Each middleware is called in order.
When
await next()is called, the next middleware is executed.After the
next()resolves, middleware can perform post-processing.The chain continues until a response is generated.
Each middleware can:
Process the incoming request.
Pass control to the next middleware using
await next().Short-circuit the chain by returning a response.
Modify the context object shared across all handlers.
What is Middleware For?
Middleware enables separation of concerns by handling cross-cutting functionality like:
Authentication
Basic Auth: Validates username/password credentials.
Bearer Auth: Validates bearer tokens.
JWT: Verifies JSON Web Tokens and stores payload in context.
HTTP Utilities
CORS: Handles Cross-Origin Resource Sharing headers.
ETag: Generates ETags and returns 304 responses for unchanged content.
Cache: Implements HTTP caching using the Cache API.
Security
- CSRF: Protects against Cross-Site Request Forgery attacks.
Request Processing
- Validation: Validates request data (JSON, form, query, params).
How to Use Middlewares?
A middleware can be applied globally to all routes, to specific path patterns, directly to individual routes, chained together, or through sub-app routing. Each method provides different levels of scope and control over request processing.
Global Application
Apply middleware to all routes using the wildcard pattern:
app.use('*', async (c, next) => {
console.log(`${c.req.method} : ${c.req.url}`)
await next()
})
Path-Specific Application
Apply middleware to routes matching a path pattern:
// Apply to all routes under /api
app.use('/api/*', cors())
// Apply to specific route
app.use('/hello', async (c, next) => {
await next()
c.res.headers.append('x-message', 'custom-header')
})
Route-Level Application
Pass middleware directly as arguments to route methods:
// Single middleware with handler
app.get('/protected', authMiddleware, (c) => {
return c.text('Authorized')
})
// Multiple middleware chained
app.get('/api/data',
middleware1,
middleware2,
(c) => c.json({ data: 'success' })
)
Chained Application
Chain multiple middleware calls for the same path:
app
.use('/chained/*', async (c, next) => {
c.req.raw.headers.append('x-before', 'abc')
await next()
})
.use(async (c, next) => {
await next()
c.header('x-after', c.req.header('x-before'))
})
.get('/chained/abc', (c) => {
return c.text('GET chained')
})
Sub-Application Routing
Apply middleware through sub-apps using app.route():
const api = new Hono()
api.use('*', async (c, next) => {
await next()
c.res.headers.append('x-custom-a', 'a')
})
const middleware = new Hono()
middleware.use('*', async (c, next) => {
await next()
c.res.headers.append('x-custom-b', 'b')
})
app.route('/api', middleware)
app.route('/api', api)
Hono Validation System
Hono's validation system is built on middleware that intercepts requests, validates specific parts (body, query parameters, headers, path parameters), and either allows the request to proceed or returns validation errors.
hono/validator
hono/validator is a middleware that validates incoming request data across different targets (JSON body, form data, query parameters, path parameters, headers, and cookies) with full TypeScript type safety.
The validator function creates middleware that:
Extracts data from a specific request target. The supported targets are:
Body Validation (
json): Validates JSON request bodies.Query Parameters (
query): Validates URL query strings.Path Parameters (
param): Validates route parameters.Headers (
header): Validates HTTP headers.Form Parameters (
form): Validates form data from the request body.Cookies (
cookie): Validates HTTP cookies.
Runs a validation function on the extracted data.
Either returns an error response (short-circuiting) or stores validated data.
Makes validated data accessible via
c.req.valid(target).
app.get('/search',
validator('query', (value, c) => {
if (!value.q) {
return c.text('Parameter not found', 400)
}
return value as { q: string }
}),
(c) => {
const { q } = c.req.valid('query')
return c.text(`Searching ${q}!!!`, 200)
}
)
The validator function receives:
value: The extracted data from the target.c: TheContextobject.
It can return:
Validated data: Stored and accessible via
c.req.valid().A Response object: Short-circuits the middleware chain with an error response.
Throw an error: Propagates through the error handler.
Based on this validation system, the ecosystem provides adapters for multiple validation libraries:
@hono/zod-validator: Uses Zod, supports v3/v4.@hono/valibot-validator: Uses Valibot, a modular validation approach.@hono/typebox-validator: Uses TypeBox, JSON Schema compliance.@hono/standard-validator: Uses Standard Schema V1 specification.
Implementing Zod Validations
Let's start by installing the following package:
npm install @hono/zod-validator
The validation starts with defining schemas. In the features/stores/store.ts file, we define the store data structure:
import { z } from 'zod';
export const storeSchema = z.object({
storeId: z.uuidv7(),
name: z.string().min(1).max(1024),
url: z.url().min(1).max(2048),
});
export type Store = z.infer<typeof storeSchema>;
export const stores: Store[] = [];
This schema enforces several validation rules:
storeIdmust be a valid UUIDv7.namemust be a non-empty string with a maximum length of 1024 characters.urlmust be a valid URL with a maximum length of 2048 characters.
The Store type is automatically inferred from the schema, ensuring TypeScript types and runtime validation stay synchronized. The pagination schema in the types/pagination.ts file demonstrates Zod's advanced features:
import { z } from 'zod';
const DEFAULT_PAGE_NUMBER = 1;
const DEFAULT_PAGE_SIZE = 10;
const MAX_PAGE_SIZE = 100;
export const paginationSchema = z.object({
pageNumber: z.coerce.number().min(1).optional().default(DEFAULT_PAGE_NUMBER),
pageSize: z.coerce
.number()
.min(1)
.max(MAX_PAGE_SIZE)
.optional()
.default(DEFAULT_PAGE_SIZE),
});
export const createPage = <TResult>(
items: TResult[],
totalCount: number,
pageNumber: number,
pageSize: number
) => {
const totalPages = Math.ceil(totalCount / pageSize);
return {
items,
pageNumber,
pageSize,
totalPages,
totalCount,
};
};
The z.coerce.number() method automatically converts string query parameters to numbers, which is essential since HTTP query parameters are always strings. The features/stores/add-store.ts file demonstrates body validation:
import { Hono } from 'hono';
import { v7 } from 'uuid';
import { StatusCodes } from 'http-status-codes';
import { stores, storeSchema } from './store.js';
import { zValidator } from '@hono/zod-validator';
const schema = storeSchema.omit({ storeId: true });
export const addRoute = new Hono().post(
'/',
zValidator('json', schema),
async c => {
const { name, url } = c.req.valid('json');
const store = { name, url, storeId: v7() };
stores.push(store);
return c.json(store, StatusCodes.CREATED);
}
);
Uses
storeSchema.omit({ storeId: true })to generate a new schema excluding thestoreIdproperty since it's generated server-side.Validates the JSON request body with the
zValidator('json', schema)middleware.Provides type-safe access to
nameandurlfrom the validated body.
For retrieving a specific store in the features/stores/get-store.ts file, we validate the path parameter:
import { Hono } from 'hono';
import { stores, storeSchema } from './store.js';
import { StatusCodes } from 'http-status-codes';
import { zValidator } from '@hono/zod-validator';
const schema = storeSchema.pick({ storeId: true });
export const getRoute = new Hono().get(
'/:storeId',
zValidator('param', schema),
async c => {
const { storeId } = c.req.valid('param');
const store = stores.find(s => s.storeId === storeId);
if (!store) {
return c.json(
{ message: `Store ${storeId} not found` },
StatusCodes.NOT_FOUND
);
}
return c.json(store, StatusCodes.OK);
}
);
storeSchema.pick({ storeId: true })creates a new schema with only thestoreIdfield.This ensures the URL parameter is a valid UUIDv7.
The validated
storeIdis type-safe and guaranteed to match the schema.
In the features/stores/list-stores.ts file, we validate query parameters for listing stores:
import { Hono } from 'hono';
import { stores } from './store.js';
import { StatusCodes } from 'http-status-codes';
import { paginationSchema, createPage } from '@/types/pagination.js';
import { z } from 'zod';
import { zValidator } from '@hono/zod-validator';
const schema = paginationSchema.extend({
name: z.string().optional(),
});
export const listRoute = new Hono().get(
'/',
zValidator('query', schema),
async c => {
const { pageNumber, pageSize, name } = c.req.valid('query');
const pn = pageNumber;
const pz = pageSize;
let filteredStores = stores;
if (name) {
filteredStores = stores.filter(store =>
store.name.toLowerCase().includes(name.toLowerCase())
);
}
const totalCount = filteredStores.length;
const startIndex = (pn - 1) * pz;
const endIndex = startIndex + pz;
const page = filteredStores.slice(startIndex, endIndex);
return c.json(createPage(page, totalCount, pn, pz), StatusCodes.OK);
}
);
We extend the paginationSchema with an optional
namefilter.The
zValidator('query', schema)middleware validates query parameters before the handler executes.c.req.valid('query')provides type-safe access to validated query parameters.If validation fails, a
400error is returned automatically.
The edit store endpoint in the features/stores/edit-store.ts file shows how to validate request parts:
import { Hono } from 'hono';
import { StatusCodes } from 'http-status-codes';
import { stores, storeSchema } from './store.js';
import { zValidator } from '@hono/zod-validator';
const paramSchema = storeSchema.pick({ storeId: true });
const bodySchema = storeSchema.pick({ name: true, url: true });
export const editRoute = new Hono().put(
'/:storeId',
zValidator('param', paramSchema),
zValidator('json', bodySchema),
async c => {
const { storeId } = c.req.valid('param');
const { name, url } = c.req.valid('json');
const store = stores.find(s => s.storeId === storeId);
if (!store) {
return c.json(
{ message: `Store ${storeId} not found` },
StatusCodes.NOT_FOUND
);
}
store.name = name;
store.url = url;
return c.json(store, StatusCodes.OK);
}
);
Multiple validators can be chained, each validating a different part of the request. The order matters: validators execute in the order they're declared.
The combination of Hono's middleware system and Zod's type-safe validation provides a powerful foundation for building robust APIs with minimal boilerplate. By mastering validation patterns, we'll build APIs that are more secure, maintainable, and easier to work with for both our team and API consumers. You can find all the code here. Thanks, and happy coding.




