Validation
Check a JSON body, a path parameter and a query value before the handler runs, with Hono's validator, and answer 400 with a message that names the problem.
What you'll learn
- Put validator('json', …) from hono/validator in front of a handler and answer 400 from inside it
- Read the validated, typed value with c.req.valid('json') instead of parsing the body again
- Validate a path parameter and a query value the same way, and share a validator between routes
await c.req.json<{ name: string; age: number }>() tells TypeScript what the body looks like. It tells the running program nothing. A client can send { "name": "", "age": -2 }, or { "nmae": "Tom" }, or {}, and the handler from lesson 3 stores a cat with name: undefined and answers 201. Every route that reads input has this problem, and the fix is the same each time: check the input before the handler sees it, and answer 400 with a message that says what was wrong. Hono ships a small validator for exactly that, and it is middleware, so it sits in front of the handler like everything else that runs before it.
The validator
import { validator } from 'hono/validator';
app.post(
'/posts',
validator('json', (value, c) => {
if (typeof value.title !== 'string') return c.json({ error: 'title is required' }, 400);
return { title: value.title as string };
}),
(c) => {
const { title } = c.req.valid('json');
return c.json({ created: title }, 201);
},
);
validator(target, callback) takes what to validate and a function that receives the value and the context. The callback decides between two outcomes. Return a response, and that response is sent; the handler never runs. Return anything else, and that value is stored as the validated data, the chain continues, and the handler reads it with c.req.valid(target). Because the handler receives what the callback returned, the callback is also where the shape is settled: return only the fields you checked, and unknown fields never reach the handler. TypeScript infers the type of c.req.valid('json') from the callback's return type, which is why the casts in the example matter.
The targets are json, form, query, param, header and cookie. For json and form the request must carry a matching content-type; a JSON body without application/json arrives in the callback as {}. A body that claims to be JSON and is not is refused by the validator itself with 400 and Malformed JSON in request body, which is better than the 500 an unguarded c.req.json() gives.
Parameters and query strings are input too
A path parameter that should be a number arrives as a string, and /cats/abc is not a cat that does not exist, it is a request that makes no sense. The param target validates it, and the callback can convert as it checks:
const idParam = validator('param', (value, c) => {
if (!/^[0-9]+$/.test(value.id)) return c.json({ error: 'id must be a number' }, 400);
return { id: Number(value.id) };
});
app.get('/items/:id', idParam, (c) => {
const { id } = c.req.valid('param'); // a number
...
});
Assigned to a constant, the validator is reused on every route with an :id. The query target works the same way over c.req.queries(): a key given once is a string, a key repeated is an array, and a key absent is undefined, so the callback decides what absence means.
Several validators on one route
Validators are middleware, so a route can take more than one, each answering for its target:
app.put('/posts/:id', validator('param', …), validator('json', …), (c) => {
const { id } = c.req.valid('param');
const body = c.req.valid('json');
...
});
The first one to return a response ends the request; the message a client sees names the first problem found.
Beyond this validator
The docs call Hono's validator "very thin" on purpose and recommend a schema library for real applications, usually @hono/zod-validator, whose zValidator('json', schema) replaces the callback with a Zod schema and types the handler from it. The middleware, the early return and c.req.valid() are the same whichever library fills in the check.
Your task
The cats API stores whatever it is sent. Put validators in front of it:
POST /cats:namemust be a non-empty string, else400with{ "error": "name must be a non-empty string" };agemust be a non-negative integer, else400with{ "error": "age must be a non-negative integer" }. Hand the handler onlynameandage, and read them withc.req.valid('json').- A shared
paramvalidator for:id: digits only, else400with{ "error": "id must be a number" }; hand the handler the id as a number. Use it onGET /cats/:idandDELETE /cats/:id. GET /cats: whenlimitis in the query string it must be an integer from 1 to 100, else400with{ "error": "limit must be between 1 and 100" }; the handler slices the list with it.
When it fails
POST /catswith an empty name still answers201: the check is inside the handler afterc.req.json(), or the validator returned the value instead of a response. Refuse by returningc.json(…, 400)from the callback.- The stored cat has a
color: the callback returnedvalueunchanged. Return an object with only the checked fields. c.req.valid('json')is a type error onname: the callback's return type does not say what the fields are. Cast,value.name as string, or build the object with typed fields.GET /cats/abcanswers404: the param validator is missing on that route, soNumber('abc')becameNaNand the lookup failed. PutidParambetween the path and the handler.GET /cats?limit=2answers every cat: the handler readc.req.query('limit')and got the string, or the validator returned{ limit: undefined }for every input. Return the converted number.
Remember
validator(target, (value, c) => …)is middleware: return a response to refuse, return the cleaned value to accept.- The handler reads that value with
c.req.valid(target), typed from what the callback returned. - Targets:
json,form,query,param,header,cookie;jsonandformneed a matchingcontent-type. - Convert while validating (
Number(value.id)) so handlers never see strings that should be numbers.
Stuck? Show a hint
validator(target, (value, c) => …) is a middleware: return c.json({ error }, 400) to refuse, or return the cleaned value to accept. Whatever the callback returns is what c.req.valid(target) gives the handler, so return only the fields you checked. For param, value.id is the string from the path; for query, value.limit is undefined when absent. Number.isInteger and a regular expression such as /^[0-9]+$/ do the checks.