Validation and errors

Handle errors centrally

Use not-found and error handlers while preserving expected domain outcomes and useful operator context.

Expected outcomes belong in handlers. Unexpected failures belong in a central error hook. Keep those two paths separate.

A missing bookmark returns a controlled 404 from the route:

app.get('/bookmarks/:id', async (c) => {
  const bookmark = await bookmarks.find(c.req.param('id'))
  if (!bookmark) {
    return c.json({ error: 'Not found' }, 404)
  }
  return c.json(bookmark)
})

A storage exception reaches onError with a request id and a safe client body:

app.onError((err, c) => {
  const requestId = c.get('requestId')
  console.error('request failed', { requestId, message: err.message })

  return c.json({ error: 'Internal error', requestId }, 500)
})

app.notFound((c) => c.json({ error: 'Route not found' }, 404))

Check each path with curl:

curl -s -w '\n%{http_code}\n' http://localhost:3000/bookmarks/missing-id
curl -s -w '\n%{http_code}\n' http://localhost:3000/not-a-route

The first should return {"error":"Not found"} with 404. The second returns {"error":"Route not found"} with 404 from notFound. Different messages help clients and operators tell domain misses from bad URLs.

A realistic failure: you throw new Error('Not found') inside the handler instead of returning 404. Curl then gets 500 {"error":"Internal error","requestId":"..."} and your logs look like a crash. Fix by returning explicit 4xx responses for expected branches and reserve onError for real exceptions.

Validation middleware returns 400 before your handler runs. That is expected, not an onError case:

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

You should see 400 with validator JSON, not 500.

Do not return stack traces to the client. Log them server-side with the request id.

Vitest can lock this in:

expect((await app.request('/bookmarks/nope')).status).toBe(404)
expect((await app.request('/nope')).status).toBe(404)

My rule: if the client could fix the request, return 4xx from the handler. If infrastructure broke, use onError.

Log the stack on 500 paths only. Operators grep logs by requestId from the client JSON. Users never see err.stack in the response body.

Add one Vitest case that forces the bookmark store to throw and asserts 500 plus { error: 'Internal error', requestId: expect.any(String) }.

notFound and handler 404 are both 404 but may use different JSON messages. Document both shapes if they differ.

Use HTTPException sparingly for shared utilities. Handlers should still return explicit JSON for domain cases you expect every day.

Try this on your own project: audit throws in route files and convert expected failures to explicit responses.

Lesson completed