Zod validator: schema validation adapter
A validator adapter that integrates Zod schemas with Hectoday HTTP’s validation system.
The code
import type {
InferSchema,
InferSchemaError,
ValidateResult,
ValidationIssue,
ValidationPart,
Validator,
} from "@hectoday/http";
import type { ZodType } from "zod";
export const zodValidator: Validator<ZodType> = {
validate<S extends ZodType>(
schema: S,
input: unknown,
part: ValidationPart,
): ValidateResult<InferSchema<S>, InferSchemaError<S>> {
const result = schema.safeParse(input);
if (result.success) {
return { ok: true, value: result.data as InferSchema<S> };
}
const issues: ValidationIssue[] = result.error.issues.map((issue) => ({
part,
path: issue.path.map(String),
message: issue.message,
code: issue.code,
}));
return {
ok: false,
issues,
error: result.error as InferSchemaError<S>,
};
},
};Installation
First, install Zod:
# Deno
deno add npm:zod
# Bun
bun add zod
# npm
npm install zodUsage
Basic setup
import { setup, route } from "@hectoday/http";
import { z } from "zod";
// Copy the zodValidator code above here
// Or save it to helpers/zodValidator.ts
const app = setup({
validator: zodValidator,
handlers: [
route.post("/users", {
request: {
body: z.object({
name: z.string(),
email: z.string().email(),
}),
},
resolve: (c) => {
if (!c.input.ok) {
return Response.json(
{ error: "Invalid input", issues: c.input.issues },
{ status: 400 }
);
}
// c.input.body is typed as { name: string; email: string }
const { name, email } = c.input.body;
return Response.json({ id: 1, name, email }, { status: 201 });
},
}),
],
});With params, query, and body
route.post("/orgs/:orgId/users", {
request: {
params: z.object({
orgId: z.string().uuid(),
}),
query: z.object({
notify: z.enum(["email", "sms", "none"]).default("email"),
}),
body: z.object({
name: z.string().min(1).max(100),
email: z.string().email(),
role: z.enum(["admin", "member", "viewer"]),
}),
},
resolve: (c) => {
if (!c.input.ok) {
return Response.json(
{ error: "Validation failed", issues: c.input.issues },
{ status: 400 }
);
}
// All fully typed!
const orgId = c.input.params.orgId; // string (UUID)
const notify = c.input.query.notify; // "email" | "sms" | "none"
const { name, email, role } = c.input.body;
return Response.json({
orgId,
notify,
user: { name, email, role }
});
},
});Advanced Zod features
Transformations
route.get("/users", {
request: {
query: z.object({
page: z.string().transform(Number).pipe(z.number().int().positive()),
limit: z.string().transform(Number).pipe(z.number().int().min(1).max(100)),
}),
},
resolve: (c) => {
if (!c.input.ok) {
return Response.json({ error: c.input.issues }, { status: 400 });
}
// c.input.query.page is number (not string!)
// c.input.query.limit is number (not string!)
const { page, limit } = c.input.query;
return Response.json({
users: [],
pagination: { page, limit, total: 0 },
});
},
});Refinements
route.post("/register", {
request: {
body: z.object({
email: z.string().email(),
password: z.string().min(8),
confirmPassword: z.string(),
}).refine((data) => data.password === data.confirmPassword, {
message: "Passwords don't match",
path: ["confirmPassword"],
}),
},
resolve: (c) => {
if (!c.input.ok) {
return Response.json({ error: c.input.issues }, { status: 400 });
}
const { email, password } = c.input.body;
return Response.json({ success: true });
},
});Optional fields
route.patch("/users/:id", {
request: {
body: z.object({
name: z.string().optional(),
email: z.string().email().optional(),
bio: z.string().max(500).optional(),
}),
},
resolve: (c) => {
if (!c.input.ok) {
return Response.json({ error: c.input.issues }, { status: 400 });
}
// c.input.body: { name?: string; email?: string; bio?: string }
const updates = c.input.body;
return Response.json({ updated: updates });
},
});Default values
route.get("/search", {
request: {
query: z.object({
q: z.string(),
sort: z.enum(["relevance", "date", "popularity"]).default("relevance"),
order: z.enum(["asc", "desc"]).default("desc"),
limit: z.string().transform(Number).pipe(z.number().default(20)),
}),
},
resolve: (c) => {
if (!c.input.ok) {
return Response.json({ error: c.input.issues }, { status: 400 });
}
const { q, sort, order, limit } = c.input.query;
// sort defaults to "relevance" if not provided
// order defaults to "desc" if not provided
// limit defaults to 20 if not provided
return Response.json({ query: q, sort, order, limit, results: [] });
},
});Reusable schemas
Define schemas once, reuse everywhere:
// schemas/user.ts
import { z } from "zod";
export const userIdSchema = z.string().uuid();
export const createUserSchema = z.object({
name: z.string().min(1).max(100),
email: z.string().email(),
password: z.string().min(8),
});
export const updateUserSchema = createUserSchema.partial();
export const loginSchema = z.object({
email: z.string().email(),
password: z.string(),
});// routes/users.ts
import { route } from "@hectoday/http";
import { createUserSchema, updateUserSchema, userIdSchema } from "../schemas/user.ts";
const createUserRoute = route.post("/users", {
request: {
body: createUserSchema,
},
resolve: (c) => {
if (!c.input.ok) {
return Response.json({ error: c.input.issues }, { status: 400 });
}
const user = c.input.body;
return Response.json({ id: "123", ...user }, { status: 201 });
},
});
const updateUserRoute = route.patch("/users/:id", {
request: {
params: z.object({ id: userIdSchema }),
body: updateUserSchema,
},
resolve: (c) => {
if (!c.input.ok) {
return Response.json({ error: c.input.issues }, { status: 400 });
}
const { id } = c.input.params;
const updates = c.input.body;
return Response.json({ id, ...updates });
},
});Error messages
Zod provides detailed error messages:
// Request: POST /users
// Body: { name: "", email: "invalid" }
// Response:
{
"error": "Validation failed",
"issues": [
{
"path": ["name"],
"message": "String must contain at least 1 character(s)"
},
{
"path": ["email"],
"message": "Invalid email"
}
]
}Custom error messages
route.post("/users", {
request: {
body: z.object({
name: z.string().min(1, "Name is required").max(100, "Name too long"),
email: z.string().email("Invalid email address"),
age: z.number().int("Age must be a whole number").min(18, "Must be 18 or older"),
}),
},
resolve: (c) => {
if (!c.input.ok) {
return Response.json({ error: c.input.issues }, { status: 400 });
}
return Response.json({ success: true });
},
});Type inference
TypeScript automatically infers types from Zod schemas:
const userSchema = z.object({
name: z.string(),
email: z.string().email(),
age: z.number().int().positive(),
role: z.enum(["admin", "user"]),
});
route.post("/users", {
request: { body: userSchema },
resolve: (c) => {
if (!c.input.ok) {
return Response.json({ error: c.input.issues }, { status: 400 });
}
// TypeScript knows the exact type:
// c.input.body: {
// name: string;
// email: string;
// age: number;
// role: "admin" | "user";
// }
const { name, email, age, role } = c.input.body;
},
});Adapter interface
The validator adapter implements the Validator interface:
interface Validator<TSchema> {
validate<S extends TSchema>(
schema: S,
input: unknown,
part: ValidationPart,
): ValidateResult<InferSchema<S>, InferSchemaError<S>>;
}
type ValidationPart = "params" | "query" | "body";
type ValidateResult<T, TRawErr = unknown> =
| { ok: true; value: T }
| { ok: false; issues: readonly ValidationIssue[]; error?: TRawErr };
interface ValidationIssue {
part: ValidationPart;
path: readonly string[];
message: string;
code?: string;
}Other schema libraries
You can create adapters for other validation libraries:
Valibot
import type { ValidationPart, Validator } from "@hectoday/http";
import * as v from "valibot";
export const valibotValidator: Validator<v.BaseSchema> = {
validate<S extends v.BaseSchema>(schema: S, input: unknown, part: ValidationPart) {
const result = v.safeParse(schema, input);
if (result.success) {
return { ok: true as const, value: result.output };
}
return {
ok: false as const,
issues: result.issues.map((issue) => ({
part,
path: issue.path?.map((p) => String(p.key)) ?? [],
message: issue.message,
})),
error: result.issues,
};
},
};Yup
import type { ValidationPart, Validator } from "@hectoday/http";
import type { AnySchema, ValidationError } from "yup";
export const yupValidator: Validator<AnySchema> = {
validate<S extends AnySchema>(schema: S, input: unknown, part: ValidationPart) {
try {
const validated = schema.validateSync(input, { abortEarly: false });
return { ok: true as const, value: validated };
} catch (error) {
if ((error as ValidationError).name === "ValidationError") {
const validationError = error as ValidationError;
return {
ok: false as const,
issues: validationError.inner.map((err) => ({
part,
path: err.path?.split(".") ?? [],
message: err.message,
})),
error: validationError,
};
}
throw error;
}
},
};Notes
- Validator is set once in
setup()and used for all routes - Schemas are defined per route in the
requestfield - Validation happens before guards run
- Use
c.input.okto check if validation passed c.input.issuescontains detailed error informationc.rawalways contains unvalidated data- Type inference works automatically with Zod
Why not built-in?
Hectoday HTTP is validator-agnostic. Different projects use different schema libraries:
- Some use Zod
- Some use Valibot
- Some use Yup
- Some use custom validators
- Some don’t validate at all
The adapter pattern lets you plug in any validator you want. Copy this adapter and modify for your validation library of choice.