Validation
Zod validation at the route boundary; preserves Hono RPC type inference.
validate({ body, query, param }) is katajs's wrapper around @hono/zod-validator. It validates request inputs at the route boundary using Zod schemas, throws a typed ValidationError on failure, and preserves Hono's RPC type inference end-to-end so clients get the validated input types automatically.
The shape
import { Hono } from 'hono';
import { validate } from '@katajs/core';
import { z } from 'zod';
const CreatePostSchema = z.object({
title: z.string().min(1).max(200),
body: z.string().min(1).max(10_000),
});
const PostIdParam = z.object({
id: z.string().uuid(),
});
export const postsRoutes = new Hono<AppEnv>()
.post('/', ...validate({ body: CreatePostSchema }), async (c) => {
const input = c.req.valid('json'); // typed as z.infer<typeof CreatePostSchema>
return c.json({ post: await c.var.resolve('postService').create(input) });
})
.get('/:id', ...validate({ param: PostIdParam }), async (c) => {
const { id } = c.req.valid('param'); // typed as { id: string }
return c.json({ post: await c.var.resolve('postService').getById(id) });
});Three things to notice:
validate({...})returns a tuple of middlewares. You spread it (...validate(...)) into the route's middleware position.c.req.valid('json' | 'query' | 'param')returns the inferred Zod input — fully typed.- Both can mix:
validate({ body, query, param })adds three middlewares to the route, one per source.
Why a tuple, not a single middleware
Hono's RPC type inference walks the route definition: each middleware in the chain narrows the request type. @hono/zod-validator is built to do this — passing it as a single zValidator(target, schema) middleware preserves the inference. But a route handler often needs to validate body and query and params; stacking three zValidator calls inline gets noisy:
// Without `validate`:
.post(
'/',
zValidator('json', CreatePostSchema, throwHook),
zValidator('query', SomeQuery, throwHook),
zValidator('param', SomeParam, throwHook),
async (c) => { /* ... */ }
)validate({...}) is sugar that produces the same tuple of zValidator(...) middlewares with a shared throw-on-failure hook. The route reads cleaner:
// With `validate`:
.post(
'/',
...validate({ body: CreatePostSchema, query: SomeQuery, param: SomeParam }),
async (c) => { /* ... */ }
)How failure becomes a ValidationError
@hono/zod-validator accepts a hook that runs after each validation attempt. validate() installs a hook that throws:
function makeHook(label: 'body' | 'query' | 'param') {
return (result, _c) => {
if (!result.success && result.error) {
const issues = result.error.issues.map((i) => ({
path: [label, ...i.path],
message: i.message,
}));
throw new ValidationError(issues);
}
};
}The throw propagates through Hono's call stack and lands in errorMapper, which maps ValidationError (an AppError subclass) to a 400 response with structured issue data:
{
"error": "validation_failed",
"message": "Request validation failed",
"issues": [
{ "path": ["body", "title"], "message": "Required" },
{ "path": ["body", "body"], "message": "String must contain at least 1 character(s)" }
],
"requestId": "..."
}The path prefix tells the client where the issue is — body, query, or param — followed by the Zod-reported path inside that source. A field validator on body.title shows up as ["body", "title"]; a refinement on the whole body shows up as just ["body"].
RPC type flow
The whole point of using @hono/zod-validator (over a hand-rolled validator) is that Hono's RPC client gets the typed input shapes for free. Given:
// src/app.ts
export type AppType = typeof app;// any TypeScript client
import { hc } from 'hono/client';
import type { AppType } from '../api/src/app';
const client = hc<AppType>('https://api.example.com');
const res = await client.posts.$post({
json: {
title: 'hi', // ← required, must be a string
body: 'world', // ← required, must be a string
// anyExtraField: 1, // ← TS error: unknown property
},
});
const data = await res.json(); // ← typedThe client's json parameter is typed as z.infer<typeof CreatePostSchema> because validate({ body: CreatePostSchema }) flowed that type into the route. No code generation, no separate API spec — the schema is the source of truth.
This also works for query strings and route params:
// Server:
.get('/:id', ...validate({ param: PostIdParam, query: ListQuery }), ...)
// Client:
await client.posts[':id'].$get({
param: { id: '...' }, // typed as z.infer<typeof PostIdParam>
query: { page: '1' }, // typed as z.infer<typeof ListQuery>
});Where to put schemas
By convention, in each module's <module>.schema.ts:
// src/modules/posts/posts.schema.ts
import { z } from 'zod';
export const CreatePostSchema = z.object({
title: z.string().min(1).max(200),
body: z.string().min(1).max(10_000),
});
export type CreatePostInput = z.infer<typeof CreatePostSchema>;
export const PostIdParam = z.object({
id: z.string().uuid(),
});
export type PostIdParamInput = z.infer<typeof PostIdParam>;The schema definitions and their inferred TypeScript types live together. Routes import them; services accept the inferred type as their input parameter. The DB layer doesn't need them — Drizzle generates its own NewPost and Post types from the schema; you can adapt one to the other in the service.
// src/modules/posts/posts.service.ts
export type PostsService = {
create(input: CreatePostInput): Promise<Post>;
// ...
};Things validation is NOT for
validate() runs at the route boundary. It checks that the request looks plausible. It doesn't:
- Authorize. "User can edit this post" is a separate concern. Throw
ForbiddenPostErrorfrom the service. - Check uniqueness. "Email is already taken" requires a database query. Throw
EmailTakenErrorfrom the repository or service. - Validate cross-field business rules. "Discount must be ≤ price." Zod can do simple cases via
.refine(), but complex rules belong in the service so the error message reflects domain language.
The pattern: validate shape and types at the route boundary; validate invariants and authorization in the service. Both throw AppError subclasses; both flow through errorMapper.
Optional: validate in middleware vs route
You can also call validate({...}) outside a route handler — e.g., in a custom middleware that wants to enforce a header schema across many routes:
import { defineMiddleware } from '@katajs/core';
const HeaderSchema = z.object({
'x-tenant-id': z.string().uuid(),
});
export const requireTenant = defineMiddleware(async (c, next) => {
// No `validate({ header: ... })` API yet — for now, do it manually:
const result = HeaderSchema.safeParse(Object.fromEntries(c.req.raw.headers));
if (!result.success) {
throw new ValidationError(
result.error.issues.map((i) => ({ path: ['header', ...i.path], message: i.message }))
);
}
await next();
});A validate({ header: ... }) extension is on the v0.3 wishlist.
Why not pipe c.req.json() through schemas in handlers?
Some Hono codebases parse and validate inline:
.post('/', async (c) => {
const raw = await c.req.json();
const input = CreatePostSchema.parse(raw); // throws on failure
// ...
});This works at runtime, but two things break:
- No RPC types. The client doesn't know the request body's shape —
client.posts.$post({ json: ... })acceptsunknown. - No standardized error.
CreatePostSchema.parse(...)throws aZodError, not aValidationError. YourerrorMappercatches it as a generic 500 unless you handle it specifically.
validate({...}) solves both — it routes through @hono/zod-validator (which flows types into RPC) and throws ValidationError (which errorMapper knows about).
Summary
validate({ body, query, param })returns a tuple of middlewares; spread it into a route.- Validates with Zod, preserves Hono RPC types, throws
ValidationErroron failure. c.req.valid('json' | 'query' | 'param')is typed as the Zod input.- Errors land in
errorMapperand render as 400 with structuredissues. - Validation is shape/type only; business rules and authorization belong in services.