Encodera

Thanks for reading!

Donate to the site — we work hard for you.

Contents

Validation with zod

Data from the client cannot be taken on trust: a post title may be empty, a number may be a word, and fields may be missing altogether. The zod library describes the expected shape of data with a schema, and the @hono/zod-validator package turns the schema into middleware that checks the request before the handler.

Installation

npm install zod @hono/zod-validator

The post schema

Schemas are handy to keep in a separate file. Create src/schemas.ts:

// src/schemas.ts
import { z } from 'zod'
 
export const postSchema = z.object({
  title: z.string().min(1).max(200),
  body: z.string().min(1),
})

The schema says: expect an object with non-empty strings title (at most 200 characters) and body.

Checking the request body

Attach the validator to the post creation route:

// src/routes/posts.ts
import { Hono } from 'hono'
import { zValidator } from '@hono/zod-validator'
import { postSchema } from '../schemas.js'
 
const posts = new Hono()
 
posts.post('/', zValidator('json', postSchema), (c) => {
  const data = c.req.valid('json')
  return c.json({ id: 3, ...data }, 201)
})
 
export default posts

zValidator('json', postSchema) reads the JSON body and checks it. If the data fits, c.req.valid('json') returns it typed as { title: string; body: string }. If not, the handler is not called and the client gets code 400 with a description of the errors.

curl -X POST http://localhost:3000/posts \
  -H "Content-Type: application/json" \
  -d '{"title": ""}'

The response comes with code 400: it says that title is too short and body is missing.

What can be checked

The first argument of zValidator says where the data comes from:

TargetWhat is checkedHow to get it
jsonthe JSON bodyc.req.valid('json')
formform datac.req.valid('form')
querythe query stringc.req.valid('query')
parampath parametersc.req.valid('param')
headerheadersc.req.valid('header')

A number from the address

Parameters arrive as strings. z.coerce.number() converts the string to a number itself:

// src/routes/posts.ts
const idSchema = z.object({ id: z.coerce.number().int().positive() })
 
posts.get('/:id', zValidator('param', idSchema), (c) => {
  const { id } = c.req.valid('param')
  return c.json({ id })
})

Import z from zod at the top of the file. /posts/5 returns {"id":5} with a number, and /posts/abc returns a 400 error.

Your own error response

The third argument is a function that receives the validation result:

// src/routes/posts.ts
posts.post(
  '/',
  zValidator('json', postSchema, (result, c) => {
    if (!result.success) {
      return c.json({ error: 'Check the post title and text' }, 400)
    }
  }),
  (c) => c.json(c.req.valid('json'), 201),
)

This way the client gets a clear message instead of zod's detailed report.

Common mistake

Reading the data again with await c.req.json() after attaching the validator. Such data is unchecked and untyped. Take it only from c.req.valid('json') — that is where the validation result lives.

Thanks for reading!

Donate to the site — we work hard for you.