Validation with zod
Data from the client cannot be taken on trust: a post title may be empty, a number may be a word, and fields may be missing altogether. The zod library describes the expected shape of data with a schema, and the @hono/zod-validator package turns the schema into middleware that checks the request before the handler.
Installation
npm install zod @hono/zod-validatorThe post schema
Schemas are handy to keep in a separate file. Create src/schemas.ts:
// src/schemas.ts
import { z } from 'zod'
export const postSchema = z.object({
title: z.string().min(1).max(200),
body: z.string().min(1),
})The schema says: expect an object with non-empty strings title (at most 200 characters) and body.
Checking the request body
Attach the validator to the post creation route:
// src/routes/posts.ts
import { Hono } from 'hono'
import { zValidator } from '@hono/zod-validator'
import { postSchema } from '../schemas.js'
const posts = new Hono()
posts.post('/', zValidator('json', postSchema), (c) => {
const data = c.req.valid('json')
return c.json({ id: 3, ...data }, 201)
})
export default postszValidator('json', postSchema) reads the JSON body and checks it. If the data fits, c.req.valid('json') returns it typed as { title: string; body: string }. If not, the handler is not called and the client gets code 400 with a description of the errors.
curl -X POST http://localhost:3000/posts \
-H "Content-Type: application/json" \
-d '{"title": ""}'The response comes with code 400: it says that title is too short and body is missing.
What can be checked
The first argument of zValidator says where the data comes from:
| Target | What is checked | How to get it |
|---|---|---|
json | the JSON body | c.req.valid('json') |
form | form data | c.req.valid('form') |
query | the query string | c.req.valid('query') |
param | path parameters | c.req.valid('param') |
header | headers | c.req.valid('header') |
A number from the address
Parameters arrive as strings. z.coerce.number() converts the string to a number itself:
// src/routes/posts.ts
const idSchema = z.object({ id: z.coerce.number().int().positive() })
posts.get('/:id', zValidator('param', idSchema), (c) => {
const { id } = c.req.valid('param')
return c.json({ id })
})Import z from zod at the top of the file. /posts/5 returns {"id":5} with a number, and /posts/abc returns a 400 error.
Your own error response
The third argument is a function that receives the validation result:
// src/routes/posts.ts
posts.post(
'/',
zValidator('json', postSchema, (result, c) => {
if (!result.success) {
return c.json({ error: 'Check the post title and text' }, 400)
}
}),
(c) => c.json(c.req.valid('json'), 201),
)This way the client gets a clear message instead of zod's detailed report.
Common mistake
Reading the data again with await c.req.json() after attaching the validator. Such data is unchecked and untyped. Take it only from c.req.valid('json') — that is where the validation result lives.