Step 06 of 12
Validation & error handling
Validate bodies, query strings and params with Zod, return consistent 4xx errors, map domain errors to status codes, and catch everything else safely.
An API is only as reliable as its handling of bad input and failures. Clients send malformed JSON, wrong types, negative quantities, IDs that don't exist; databases time out; your own code has bugs. A well-built API answers every one of those with a precise, consistent response, never crashes, and never leaks internal details. This lesson validates every input with Zod, defines one error format, maps domain errors to HTTP status codes, and adds a safety net for the errors nobody anticipated.
Schemas: one definition, two jobs
A Zod schema describes the shape and rules of some data. It validates at runtime and produces a TypeScript type, so the type you use in your code is exactly what was checked:
npm install zod @hono/zod-validator// src/orders/schemas.ts
import { z } from 'zod'
export const LineItem = z.object({
sku: z.string().regex(/^[A-Z0-9-]{3,20}$/, 'SKU must be 3-20 uppercase letters, digits or dashes'),
quantity: z.number().int().min(1).max(100),
})
export const CreateOrder = z.object({
customerEmail: z.email(),
items: z.array(LineItem).min(1, 'an order needs at least one item'),
note: z.string().max(500).optional(),
})
export type CreateOrder = z.infer<typeof CreateOrder>
// { customerEmail: string; items: { sku: string; quantity: number }[]; note?: string | undefined }schema.safeParse(data) returns either { success: true, data } with typed, cleaned data, or { success: false, error } listing every problem with its path:
const result = CreateOrder.safeParse({ customerEmail: 'nope', items: [{ sku: 'pen', quantity: 0 }] })
if (!result.success) console.log(result.error.issues.map((i) => `${i.path.join('.')}: ${i.message}`))[
'customerEmail: Invalid email address',
'items.0.sku: SKU must be 3-20 uppercase letters, digits or dashes',
'items.0.quantity: Too small: expected number to be >=1'
]Validation also strips unknown keys by default. A client that sends { ..., "isAdmin": true } gets an object without isAdmin, which closes the mass-assignment hole where extra fields flow straight into a database update.
Validating requests in Hono
@hono/zod-validator turns a schema into middleware for any part of the request: json (body), query, param, header, form. Inside the handler, c.req.valid(...) returns the validated, typed value:
import { zValidator } from '@hono/zod-validator'
import { Hono } from 'hono'
import { z } from 'zod'
import { CreateOrder } from './schemas.ts'
export const ordersRoutes = new Hono()
const IdParam = z.object({ id: z.coerce.number().int().positive() })
const ListQuery = z.object({
status: z.enum(['pending', 'paid', 'shipped']).optional(),
limit: z.coerce.number().int().min(1).max(100).default(20),
cursor: z.coerce.number().int().positive().optional(),
})
ordersRoutes.post('/', zValidator('json', CreateOrder), async (c) => {
const input = c.req.valid('json') // typed as CreateOrder, already checked
const order = await createOrder(input)
return c.json(order, 201)
})
ordersRoutes.get('/', zValidator('query', ListQuery), async (c) => {
const { status, limit, cursor } = c.req.valid('query') // limit is a number, default 20
return c.json(await listOrders({ status, limit, cursor }))
})
ordersRoutes.get('/:id', zValidator('param', IdParam), async (c) => {
const { id } = c.req.valid('param') // a positive integer, not a string
return c.json(await getOrder(id))
})Query strings and path params are always strings, so their schemas use z.coerce.number() to convert first, then check. Each validator runs before the handler; if the data is invalid, the handler never runs.
One error format
By default, a failed validator returns Zod's raw error output with status 400. Clients are better served by one consistent error shape for every failure in the API, validation or otherwise:
{
"error": {
"code": "validation_failed",
"message": "The request is invalid.",
"details": [
{ "path": "items.0.quantity", "message": "Too small: expected number to be >=1" }
]
}
}A small helper produces it, and a validator wrapper applies it everywhere:
// src/http/errors.ts
import type { Context } from 'hono'
import type { ContentfulStatusCode } from 'hono/utils/http-status'
import { zValidator as zv } from '@hono/zod-validator'
import type { ValidationTargets } from 'hono'
import type { z } from 'zod'
export function errorResponse(
c: Context,
status: ContentfulStatusCode,
code: string,
message: string,
details?: unknown,
) {
return c.json({ error: { code, message, ...(details ? { details } : {}) } }, status)
}
export const validate = <T extends z.ZodType, Target extends keyof ValidationTargets>(target: Target, schema: T) =>
zv(target, schema, (result, c) => {
if (!result.success) {
const details = result.error.issues.map((i) => ({ path: i.path.join('.'), message: i.message }))
return errorResponse(c, 422, 'validation_failed', 'The request is invalid.', details)
}
})Use validate('json', CreateOrder) instead of zValidator(...) in routes, and every validation failure looks the same. Status 422 says "I understood the JSON, but it breaks the rules"; malformed JSON that can't be parsed at all is reported as a validation failure too.
Domain errors and HTTPException
Not every error is about input shape. "Order 42 doesn't exist", "this order is already shipped and can't be cancelled", "that email is already registered" are rules of your domain, discovered deep inside services that shouldn't know about HTTP.
Define domain errors in plain TypeScript, with no HTTP in them:
// src/errors.ts
export class NotFoundError extends Error {
constructor(resource: string, id: string | number) {
super(`${resource} ${id} not found`)
this.name = 'NotFoundError'
}
}
export class ConflictError extends Error {
readonly code: string
constructor(message: string, code = 'conflict') {
super(message)
this.name = 'ConflictError'
this.code = code // a plain field, not a parameter property: Node's type stripping can't run those
}
}// src/orders/service.ts
export async function cancelOrder(id: number) {
const order = await repo.find(id)
if (!order) throw new NotFoundError('order', id)
if (order.status === 'shipped') throw new ConflictError('a shipped order cannot be cancelled', 'order_already_shipped')
return repo.update(id, { status: 'cancelled' })
}The service throws; it doesn't care whether the caller is an HTTP handler, a job or a test. The translation to status codes happens in one place: the app's error handler.
For errors that genuinely are about HTTP, such as a missing header in a middleware, Hono provides HTTPException:
import { HTTPException } from 'hono/http-exception'
throw new HTTPException(401, { message: 'missing API key' })A central error handler
app.onError catches every error thrown by any middleware or handler, sync or async:
// src/app.ts
import { Hono } from 'hono'
import { HTTPException } from 'hono/http-exception'
import { ConflictError, NotFoundError } from './errors.ts'
import { errorResponse } from './http/errors.ts'
import { ordersRoutes } from './orders/routes.ts'
export const app = new Hono()
app.route('/orders', ordersRoutes)
app.notFound((c) => errorResponse(c, 404, 'route_not_found', `No route for ${c.req.method} ${c.req.path}`))
app.onError((err, c) => {
if (err instanceof NotFoundError) return errorResponse(c, 404, 'not_found', err.message)
if (err instanceof ConflictError) return errorResponse(c, 409, err.code, err.message)
if (err instanceof HTTPException) return errorResponse(c, err.status, 'http_error', err.message)
// Anything else is a bug or an outage: log everything, reveal nothing.
console.error({ err, method: c.req.method, path: c.req.path })
return errorResponse(c, 500, 'internal_error', 'Something went wrong. Please try again.')
})The last branch matters most. An unexpected error's message and stack can contain SQL, file paths, hostnames or data from other users; send them to your logs, and send the client a generic message. Include a request ID (from the requestId middleware) in both, so a user's bug report can be matched to the log line.
Errors outside requests
onError only covers request handling. Two more places need attention:
- Fire-and-forget promises.
sendEmail(order)withoutawaitruns outside the request; if it rejects, nothing catches it, and Node's default for an unhandled rejection is to crash the process. Eitherawaitit, or attach.catch(err => log(err)), or (better) enqueue a background job. - Process-level handlers. Log
uncaughtExceptionandunhandledRejectionwith full detail, then exit and let your platform restart the service:
process.on('unhandledRejection', (reason) => {
console.error({ msg: 'unhandled rejection', reason })
process.exit(1)
})Practice
Extend the bookmarks API from the previous lesson:
- Define Zod schemas:
CreateBookmark(urlmust be an http(s) URL,title1–200 characters,tagsup to 10 lowercase words, default[]), aListQuerywith coercedlimit(1–100, default 20) and optionaltag, and anIdParam. - Use a
validatehelper that returns the unified 422 error format. - Add
PATCH /bookmarks/:idthat accepts a partial update (CreateBookmark.partial()), and a rule: a bookmark's URL can't be changed to one that's already bookmarked (409duplicate_url). Creating a duplicate URL is also a 409. - Throw
NotFoundErrorandConflictErrorfrom the store, and map them inapp.onError. Make an unexpected error return a generic 500. - Check with
app.request: a 422 with details for bad input, a 404, a 409, and that an extraidfield in the body is ignored.
Show solution
// src/bookmarks/schemas.ts
import { z } from 'zod'
export const CreateBookmark = z.object({
url: z.url({ protocol: /^https?$/ }),
title: z.string().trim().min(1).max(200),
tags: z.array(z.string().regex(/^[a-z0-9-]+$/, 'tags are lowercase words')).max(10).default([]),
})
export const UpdateBookmark = CreateBookmark.partial()
export const ListQuery = z.object({
tag: z.string().optional(),
limit: z.coerce.number().int().min(1).max(100).default(20),
})
export const IdParam = z.object({ id: z.coerce.number().int().positive() })
export type CreateBookmark = z.infer<typeof CreateBookmark>
export type UpdateBookmark = z.infer<typeof UpdateBookmark>// src/bookmarks/store.ts
import { ConflictError, NotFoundError } from '../errors.ts'
import type { CreateBookmark, UpdateBookmark } from './schemas.ts'
export type Bookmark = CreateBookmark & { id: number; createdAt: string }
const bookmarks = new Map<number, Bookmark>()
let nextId = 1
function assertUniqueUrl(url: string, exceptId?: number) {
for (const b of bookmarks.values())
if (b.url === url && b.id !== exceptId) throw new ConflictError(`${url} is already bookmarked`, 'duplicate_url')
}
export const store = {
list: (tag: string | undefined, limit: number) =>
[...bookmarks.values()].filter((b) => !tag || b.tags.includes(tag)).slice(0, limit),
get(id: number) {
const b = bookmarks.get(id)
if (!b) throw new NotFoundError('bookmark', id)
return b
},
create(input: CreateBookmark) {
assertUniqueUrl(input.url)
const b: Bookmark = { ...input, id: nextId++, createdAt: new Date().toISOString() }
bookmarks.set(b.id, b)
return b
},
update(id: number, changes: UpdateBookmark) {
const current = store.get(id)
if (changes.url) assertUniqueUrl(changes.url, id)
const updated = { ...current, ...changes }
bookmarks.set(id, updated)
return updated
},
}// src/bookmarks/routes.ts
import { Hono } from 'hono'
import { validate } from '../http/errors.ts'
import { CreateBookmark, IdParam, ListQuery, UpdateBookmark } from './schemas.ts'
import { store } from './store.ts'
export const bookmarkRoutes = new Hono()
.get('/', validate('query', ListQuery), (c) => {
const { tag, limit } = c.req.valid('query')
return c.json({ items: store.list(tag, limit) })
})
.post('/', validate('json', CreateBookmark), (c) => c.json(store.create(c.req.valid('json')), 201))
.get('/:id', validate('param', IdParam), (c) => c.json(store.get(c.req.valid('param').id)))
.patch('/:id', validate('param', IdParam), validate('json', UpdateBookmark), (c) =>
c.json(store.update(c.req.valid('param').id, c.req.valid('json'))),
)src/errors.ts, src/http/errors.ts and src/app.ts are as shown in the lesson, with app.route('/bookmarks', bookmarkRoutes). Checking it:
// check.ts
import { app } from './src/app.ts'
const post = (body: unknown) =>
app.request('/bookmarks', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) })
const bad = await post({ url: 'ftp://x', title: '', tags: ['Not OK'] })
console.log(bad.status, JSON.stringify(await bad.json()))
// 422 {"error":{"code":"validation_failed",...,"details":[{"path":"url",...},{"path":"title",...},{"path":"tags.0",...}]}}
const created = await post({ url: 'https://hono.dev', title: 'Hono', id: 999 })
console.log(created.status, (await created.json()).id) // 201 1 (the client's id was stripped)
console.log((await post({ url: 'https://hono.dev', title: 'Again' })).status) // 409
console.log((await app.request('/bookmarks/42')).status) // 404Chaining .get().post()... on the Hono instance (rather than separate statements) keeps the full route types, which the full-stack roadmap uses to generate a typed client. The store knows nothing about HTTP: it throws NotFoundError and ConflictError, and app.onError decides they're 404 and 409.
Key takeaways
- A Zod schema validates at runtime and produces the matching TypeScript type with
z.infer; unknown keys are stripped by default. - Validate body, query and params with
zValidatorand read typed values withc.req.valid(...); coerce query and path strings. - Return one consistent error shape for every failure, with field-level details for validation errors (422).
- Throw domain errors from services, and translate them to status codes in a single
app.onError. - Log unexpected errors in full but return a generic 500, and don't leave promises unhandled outside requests.
Check yourself
Quiz
0/4 correct · 4 left
1.A client sends {"title": "x", "isAdmin": true} to an endpoint validated by z.object({ title: z.string() }). What does the handler receive from c.req.valid('json')?
2.Why does ListQuery use z.coerce.number() for limit?
3.Where should the decision 'NotFoundError becomes HTTP 404' live?
4.An unexpected database error reaches onError. What should the client receive?
Finished reading?
Mark it complete to fill in your roadmap.