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.

17 min 8 sections

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:

bash
npm install zod @hono/zod-validator
ts
// 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:

ts
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}`))
text
[
  '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:

ts
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:

json
{
  "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:

ts
// 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:

ts
// 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
  }
}
ts
// 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:

ts
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:

ts
// 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) without await runs outside the request; if it rejects, nothing catches it, and Node's default for an unhandled rejection is to crash the process. Either await it, or attach .catch(err => log(err)), or (better) enqueue a background job.
  • Process-level handlers. Log uncaughtException and unhandledRejection with full detail, then exit and let your platform restart the service:
ts
process.on('unhandledRejection', (reason) => {
  console.error({ msg: 'unhandled rejection', reason })
  process.exit(1)
})

Practice

Extend the bookmarks API from the previous lesson:

  1. Define Zod schemas: CreateBookmark (url must be an http(s) URL, title 1–200 characters, tags up to 10 lowercase words, default []), a ListQuery with coerced limit (1–100, default 20) and optional tag, and an IdParam.
  2. Use a validate helper that returns the unified 422 error format.
  3. Add PATCH /bookmarks/:id that accepts a partial update (CreateBookmark.partial()), and a rule: a bookmark's URL can't be changed to one that's already bookmarked (409 duplicate_url). Creating a duplicate URL is also a 409.
  4. Throw NotFoundError and ConflictError from the store, and map them in app.onError. Make an unexpected error return a generic 500.
  5. Check with app.request: a 422 with details for bad input, a 404, a 409, and that an extra id field in the body is ignored.
Show solution
ts
// 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>
ts
// 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
  },
}
ts
// 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:

ts
// 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)                       // 404

Chaining .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 zValidator and read typed values with c.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. 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. 2.Why does ListQuery use z.coerce.number() for limit?

  3. 3.Where should the decision 'NotFoundError becomes HTTP 404' live?

  4. 4.An unexpected database error reaches onError. What should the client receive?

Finished reading?

Mark it complete to fill in your roadmap.