Skip to content

With Hono

Add runtime contracts to Hono HTTP handlers for request and response validation.

Hono is a lightweight web framework that runs everywhere. vowwch contracts wrap route handlers to validate parsed request data and response shapes at runtime.

Contracted route handler

import { Hono } from "hono"
import { contract, defineGuard } from "vowwch"
import { z } from "zod"

const app = new Hono()

const CreateUserSchema = z.object({
  name: z.string().min(1).max(100),
  email: z.string().email(),
  role: z.enum(["admin", "editor", "viewer"]),
})

type CreateUserInput = z.infer<typeof CreateUserSchema>

const isCreateUserInput = defineGuard((v) => CreateUserSchema.parse(v))

const isObject = (v: unknown): v is Record<string, unknown> => typeof v === "object" && v !== null

const createUser = contract(
  (input: CreateUserInput) => ({
    id: crypto.randomUUID(),
    ...input,
    createdAt: new Date().toISOString(),
  }),
  {
    name: "POST /users",
    input: isCreateUserInput,
    output: isObject,
    mode: "strict",
  },
)

app.post("/users", async (c) => {
  const body = await c.req.json()
  const user = createUser(body)
  return c.json(user, 201)
})

export default app

Request validation middleware

Extract contract-based validation into reusable middleware that runs before the handler.

import { Hono } from "hono"
import { contract, defineGuard, type Predicate } from "vowwch"
import { z } from "zod"
import type { Context, Next } from "hono"

function validateBody<T>(predicate: Predicate<T>) {
  const validate = contract((body: unknown) => body as T, {
    name: "requestBody",
    input: predicate,
    mode: "strict",
  })
  return async (c: Context, next: Next) => {
    const body = await c.req.json()
    c.set("validatedBody", validate(body))
    await next()
  }
}

const OrderSchema = z.object({
  productId: z.string().uuid(),
  quantity: z.number().int().positive(),
  shippingAddress: z.object({
    street: z.string(),
    city: z.string(),
    zip: z.string(),
    country: z.string().length(2),
  }),
})

const isOrder = defineGuard((v) => OrderSchema.parse(v))

const app = new Hono()

app.post("/orders", validateBody(isOrder), (c) => {
  const order = c.get("validatedBody") as z.infer<typeof OrderSchema>
  return c.json({
    orderId: crypto.randomUUID(),
    productId: order.productId,
    quantity: order.quantity,
    status: "pending",
  })
})

Response validation

Wrap handler return values in a contract to catch unexpected response shapes.

import { contract, defineGuard } from "vowwch"
import { z } from "zod"

const PaginatedSchema = z.object({
  data: z.array(z.record(z.unknown())),
  total: z.number().int().nonnegative(),
  page: z.number().int().positive(),
})

const isPaginated = defineGuard((v) => PaginatedSchema.parse(v))

const listProducts = contract(
  async (page: number) => {
    const pageSize = 20
    const offset = (page - 1) * pageSize
    const rows = await db.query("SELECT * FROM products LIMIT ? OFFSET ?", [pageSize, offset])
    const [{ total }] = await db.query("SELECT COUNT(*) as total FROM products")
    return { data: rows, total, page }
  },
  { name: "GET /products", output: isPaginated, mode: "warn" },
)

Error handling middleware

In strict mode, contract violations throw errors with name set to "VowwchViolationError" and a violation property. Catch these in Hono's error handler.

import { Hono } from "hono"
import type { Violation } from "vowwch"

const app = new Hono()

app.onError((err, c) => {
  if (err.name === "VowwchViolationError") {
    const { name, side, parserError } = (err as Error & { violation: Violation }).violation
    return c.json({ error: "Validation failed", contract: name, side, detail: parserError }, 422)
  }
  return c.json({ error: "Internal server error" }, 500)
})

Contracted route handler factory

Use createContractor to share violation handling across all route contracts.

import { createContractor, type Violation } from "vowwch"

const violations: Violation[] = []

const { contract: route } = createContractor({
  mode: "warn",
  onViolation: (v) => {
    violations.push(v)
  },
})

Apply the shared contractor to each handler:

import { defineGuard } from "vowwch"
import { z } from "zod"

const isUserId = defineGuard((v) => z.string().uuid().parse(v))
const isUserArray = (v: unknown): v is unknown[] => Array.isArray(v)

const getUser = route(
  async (id: string) => {
    const user = await db.user.findUnique({ where: { id } })
    if (!user) throw new Error("Not found")
    return user
  },
  {
    name: "GET /users/:id",
    input: isUserId,
  },
)

const listUsers = route(
  async () => {
    return db.user.findMany({ take: 50 })
  },
  {
    name: "GET /users",
    output: isUserArray,
  },
)

app.get("/users/:id", async (c) => {
  const user = await getUser(c.req.param("id"))
  return c.json(user)
})

app.get("/users", async (c) => {
  const users = await listUsers()
  return c.json(users)
})