ValidationBasics · Hono

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 will learn

Read the theory for Validation

All Basics lessons

All Hono courses

loading types…

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

Validation

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:

  1. POST /cats: name must be a non-empty string, else 400 with { "error": "name must be a non-empty string" }; age must be a non-negative integer, else 400 with { "error": "age must be a non-negative integer" }. Hand the handler only name and age, and read them with c.req.valid('json').
  2. A shared param validator for :id: digits only, else 400 with { "error": "id must be a number" }; hand the handler the id as a number. Use it on GET /cats/:id and DELETE /cats/:id.
  3. GET /cats: when limit is in the query string it must be an integer from 1 to 100, else 400 with { "error": "limit must be between 1 and 100" }; the handler slices the list with it.

When it fails

  • POST /cats with an empty name still answers 201: the check is inside the handler after c.req.json(), or the validator returned the value instead of a response. Refuse by returning c.json(…, 400) from the callback.
  • The stored cat has a color: the callback returned value unchanged. Return an object with only the checked fields.
  • c.req.valid('json') is a type error on name: 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/abc answers 404: the param validator is missing on that route, so Number('abc') became NaN and the lookup failed. Put idParam between the path and the handler.
  • GET /cats?limit=2 answers every cat: the handler read c.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; json and form need a matching content-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.