Validation and data

Validate unknown input

Treat request data as unknown at runtime and turn invalid JSON fields into precise client-facing validation errors.

TypeScript checks your code. It cannot check what a stranger sends over the network. When you write const input = await c.req.json() as Book, the as changes what the compiler believes and changes nothing about the bytes that arrived. The body could be null, an array, a string, or an object with a title of 12345.

So every request body starts as unknown, and something at runtime has to prove it has the shape we expect. That something is a schema: a description of the allowed fields, written in code, that can both check a value and report what’s wrong with it.

A schema for the writable fields

Our contract says a client may send title, author and an optional publishedYear. Here is that rule as a Zod schema:

import { z } from 'zod'

export const bookInput = z.strictObject({
  title: z.string().trim().min(1).max(200),
  author: z.string().trim().min(1).max(200),
  publishedYear: z.number().int().min(1000).max(9999).optional()
})

strictObject rejects fields we didn’t list, so { "title": "Dune", "admin": true } fails instead of quietly succeeding. trim().min(1) turns a whitespace-only title into an error. Every limit here is a decision from the contract, not a guess.

Validate before the handler runs

Hono has validation middleware that runs the schema and hands the handler the clean result. With the Zod adapter:

import { zValidator } from '@hono/zod-validator'

app.post('/books', zValidator('json', bookInput, (result, c) => {
  if (!result.success) return problem(c, 422, 'Book input is invalid', { errors: result.error.issues })
}), async c => {
  const input = c.req.valid('json')
  const book = { ...input, id: crypto.randomUUID() }
  books.push(book)
  c.header('Location', `/books/${book.id}`)
  return c.json({ book }, 201)
})

c.req.valid('json') returns the parsed, typed value. Now input really is a { title, author, publishedYear? }, and the type is true because the check happened.

Send a bad body and read the answer:

curl -i --json '{"title":"","author":"Frank Herbert","publishedYear":"1965"}' http://localhost:3000/books

You get a 422 with application/problem+json and two issues: title is too short, and publishedYear expected a number but received a string.

Validation starts before the schema

The schema is the last check, not the first. Before it runs, three cheaper checks should already have happened. Cap the body size, so nobody sends you 50 MB of JSON. Require Content-Type: application/json, and answer 415 otherwise. Turn a JSON parse failure into a 400, not an unhandled exception. We add the body limit in the retries lesson.

Reject or strip, but pick one

Unknown fields can be rejected, which is what strictObject does, or silently stripped. Both are fine. Mixing them is not, because a client with a typo like autor would succeed on one route and fail on another. Pick one policy and apply it everywhere.

Same for coercion. Trimming a title is a small kindness. Turning "1965" into 1965 hides a bug in the client, and the client should hear about it. When in doubt, be strict.

Before moving on, test the ugly cases: null, an array, an extra field, a whitespace-only string, a year of 99, and the largest title you accept. Each should give a precise 422, or a 400 when the body isn’t JSON at all.

Lesson completed