# 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.

Backend with Node.js roadmap · Building APIs · 17 min read · https://levelcode.org/roadmaps/node-backend/validation-and-errors

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.

> [!WARNING]
> Never `catch` an error just to `console.log` it and carry on as if nothing happened. Either handle it meaningfully (retry, fall back, translate it into a domain error) or let it propagate to the central handler. Swallowed errors become data corruption and hours of debugging.

## 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.

**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.

## Quiz

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')?

   A. { title: 'x', isAdmin: true }
   B. A 422 error
   C. { title: 'x' }
   D. undefined

   **Answer: C.** z.object strips keys that aren't in the schema by default, which prevents unexpected fields from flowing into your data.

2. Why does ListQuery use z.coerce.number() for limit?

   A. Query string values are always strings, so they must be converted before numeric checks
   B. To allow decimal numbers
   C. Because Zod can't validate numbers otherwise
   D. To make the parameter optional

   **Answer: A.** ?limit=20 arrives as the string "20". coerce converts it to a number, then min, max and int apply.

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

   A. In every route handler
   B. In the repository that queries the database
   C. In the Zod schema
   D. In the app's central onError handler

   **Answer: D.** Services stay independent of HTTP, and one handler applies the mapping consistently for every route.

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

   A. The error message and stack trace, to help debugging
   B. A generic 500 message, ideally with a request ID, while the full error goes to the logs
   C. A 400 Bad Request
   D. No response; the connection should be closed

   **Answer: B.** Internal error details can leak SQL, paths or other users' data. Log them in full, and return a generic message the client can quote.
