Validation and errors

Connect a schema validator

Use a schema library when it improves reuse and types without hiding the actual HTTP failure contract.

A schema library validates shape and can infer TypeScript types. The route still owns status codes, field errors, and business rules.

The create schema checks URL and title shape. The service checks whether the normalized URL already exists:

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

export const createBookmarkSchema = z.object({
  title: z.string().min(1).max(120),
  url: z.string().url()
})

app.post('/bookmarks', zValidator('json', createBookmarkSchema), async (c) => {
  const input = c.req.valid('json')
  const normalized = new URL(input.url).toString()

  const exists = await bookmarks.findByUrl(normalized)
  if (exists) {
    return c.json({ error: 'Duplicate URL', field: 'url' }, 409)
  }

  const bookmark = await bookmarks.create({ ...input, url: normalized })
  return c.json(bookmark, 201)
})

Send bad input with curl and read the exact failure:

curl -s -o /tmp/out -w '%{http_code}' -X POST http://localhost:3000/bookmarks \
  -H 'content-type: application/json' \
  -d '{"title":"Bad link","url":"not-a-url"}'

You should get status 400 and a body like {"success":false,"error":{"name":"ZodError","issues":[{"path":["url"],"message":"Invalid url"}]}} unless you customize the hook. That JSON is the contract clients will see, not a generic “validation failed” string.

Structural validation and business validation stay separate. Do not hide database lookups inside a reusable Zod schema.

Customize the hook when you need a stable client shape:

zValidator('json', createBookmarkSchema, (result, c) => {
  if (!result.success) {
    return c.json({ error: 'Invalid input', issues: result.error.issues }, 400)
  }
})

After the hook change, the same curl returns {"error":"Invalid input","issues":[...]} with status 400. Document that shape in your tests.

Reuse the schema in Vitest: createBookmarkSchema.parse({ title: 'Hono docs', url: 'https://hono.dev' }) builds fixtures that always match the server.

A realistic failure: you add .strict() to reject unknown fields but forget to update the hook. Clients sending {"title":"x","url":"https://hono.dev","tags":[]} get a Zod error shape your frontend never handled. Fix the hook and add one Vitest case that posts the extra field.

Duplicate URL conflicts should return 409 with a stable body:

curl -s -w '\n%{http_code}\n' -X POST http://localhost:3000/bookmarks \
  -H 'content-type: application/json' \
  -d '{"title":"Hono docs","url":"https://hono.dev"}'

Run twice against a persistent store. The second call should be 409 {"error":"Duplicate URL","field":"url"}, not 500 from an uncaught database error.

Vitest should include one malformed JSON POST and assert 400 before the handler runs. That proves the validator hook owns parse failures.

Keep business messages human-readable but stable across releases. Clients parse field names in CI.

Malformed JSON should never reach your create handler. The validator or parser owns that branch with a documented 400 body.

Try this on your own project: extract shared schemas to schemas/bookmark.js and keep conflict checks in the handler.

Lesson completed