Skip to main content

Validation Architecture

ZeroStarter uses Zod for runtime type validation and schema definition. Schemas are integrated with OpenAPI documentation via hono-openapi.

Schema Definition

Define reusable schemas for data models (routers/v1.ts:8-27):

Session Schema

const sessionSchema = z.object({
  createdAt: z.string().meta({ format: "date-time", example: "2026-01-21T13:06:25.712Z" }),
  expiresAt: z.string().meta({ format: "date-time", example: "2026-01-28T13:06:25.712Z" }),
  id: z.string().meta({ example: "6kpGKXeJAKfB4MERWrfdyFdKd1ZB0Czo" }),
  ipAddress: z.string().nullable().meta({ example: "202.9.121.21" }),
  token: z.string().meta({ example: "Ds8MdODZSgu57rbR8hzapFlcv6IwoIgD" }),
  updatedAt: z.string().meta({ format: "date-time", example: "2026-01-21T13:06:25.712Z" }),
  userAgent: z.string().nullable().meta({ example: "Mozilla/5.0 Chrome/143.0.0.0 Safari/537.36" }),
  userId: z.string().meta({ example: "iO8PZYiiwR6e0o9XDtqyAmUemv1Pc8tc" }),
})

User Schema

const userSchema = z.object({
  createdAt: z.string().meta({ format: "date-time", example: "2025-12-17T14:33:40.317Z" }),
  email: z.string().meta({ example: "user@example.com" }),
  emailVerified: z.boolean().meta({ example: true }),
  id: z.string().meta({ example: "iO8PZYiiwR6e0o9XDtqyAmUemv1Pc8tc" }),
  image: z.string().nullable().meta({ example: "https://example.com/avatar.png" }),
  name: z.string().meta({ example: "John Doe" }),
  updatedAt: z.string().meta({ format: "date-time", example: "2025-12-17T14:33:40.317Z" }),
})

OpenAPI Integration

Schemas are converted to OpenAPI specs using the resolver function from hono-openapi.

Response Schema Documentation

Define response schemas in route descriptions (routers/v1.ts:50-58):
describeRoute({
  tags: ["v1"],
  description: "Get current session only",
  responses: {
    200: {
      description: "OK",
      content: {
        "application/json": {
          schema: resolver(z.object({ data: sessionSchema })),
        },
      },
    },
  },
})
The resolver function converts Zod schemas to JSON Schema for OpenAPI.

Metadata for Documentation

Add metadata to schema fields for better API docs:
z.string().meta({ 
  format: "date-time",
  example: "2026-01-21T13:06:25.712Z" 
})

z.string().meta({ 
  example: "user@example.com",
  description: "User's email address" 
})

z.enum(["local", "development", "test", "staging", "production"])
  .meta({ example: env.NODE_ENV })

Health Check Response Schema

Inline schema definition for simple responses (index.ts:68-78):
schema: resolver(
  z.object({
    data: z.object({
      environment: z
        .enum(["local", "development", "test", "staging", "production"])
        .meta({ example: env.NODE_ENV }),
      message: z.string().meta({ example: "ok" }),
      version: z.string().meta({ example: BUILD_VERSION }),
    }),
  }),
)

Error Response Schemas

ZeroStarter uses consistent error response formats.

Validation Errors

Zod validation errors are automatically formatted (lib/error.ts:7-14):
if (err instanceof z.ZodError) {
  return c.json(
    {
      error: { 
        code: "VALIDATION_ERROR", 
        message: "Invalid request payload", 
        issues: err.issues 
      },
    },
    400,
  )
}
Example response:
{
  "error": {
    "code": "VALIDATION_ERROR",
    "message": "Invalid request payload",
    "issues": [
      {
        "code": "invalid_type",
        "expected": "string",
        "received": "undefined",
        "path": ["email"],
        "message": "Required"
      }
    ]
  }
}

Standard Error Format

All errors follow the same structure:
type ErrorResponse = {
  error: {
    code: string
    message: string
    issues?: ZodIssue[]
  }
}
Error codes used in ZeroStarter:
  • VALIDATION_ERROR - Request validation failed (400)
  • UNAUTHORIZED - No valid session (401)
  • FORBIDDEN - Insufficient permissions (403)
  • NOT_FOUND - Resource not found (404)
  • TOO_MANY_REQUESTS - Rate limit exceeded (429)
  • INTERNAL_SERVER_ERROR - Unhandled server error (500)

Request Validation

Validate incoming request data using Zod schemas:
import { zValidator } from "@hono/zod-validator"
import { z } from "zod"

const createPostSchema = z.object({
  title: z.string().min(1).max(255),
  content: z.string().min(1),
  published: z.boolean().default(false),
})

app.post(
  "/posts",
  zValidator("json", createPostSchema),
  async (c) => {
    const data = c.req.valid("json")
    
    const post = await db.insert(posts).values(data)
    return c.json({ data: post })
  },
)

Query Parameter Validation

Validate query strings:
const paginationSchema = z.object({
  page: z.string().transform(Number).pipe(z.number().min(1).default(1)),
  limit: z.string().transform(Number).pipe(z.number().min(1).max(100).default(20)),
})

app.get(
  "/posts",
  zValidator("query", paginationSchema),
  async (c) => {
    const { page, limit } = c.req.valid("query")
    
    const offset = (page - 1) * limit
    const data = await db.select().from(posts).limit(limit).offset(offset)
    
    return c.json({ data })
  },
)

Path Parameter Validation

Validate URL parameters:
const idParamSchema = z.object({
  id: z.string().uuid(),
})

app.get(
  "/posts/:id",
  zValidator("param", idParamSchema),
  async (c) => {
    const { id } = c.req.valid("param")
    
    const post = await db.select().from(posts).where(eq(posts.id, id)).get()
    
    if (!post) {
      return c.json({ error: { code: "NOT_FOUND", message: "Post not found" } }, 404)
    }
    
    return c.json({ data: post })
  },
)

Combining Multiple Validators

Validate different parts of the request:
app.put(
  "/posts/:id",
  zValidator("param", idParamSchema),
  zValidator("json", updatePostSchema),
  async (c) => {
    const { id } = c.req.valid("param")
    const updates = c.req.valid("json")
    
    const post = await db
      .update(posts)
      .set(updates)
      .where(eq(posts.id, id))
      .returning()
      .get()
    
    return c.json({ data: post })
  },
)

Schema Composition

Reuse and extend schemas:
const basePostSchema = z.object({
  title: z.string().min(1).max(255),
  content: z.string().min(1),
})

const createPostSchema = basePostSchema.extend({
  published: z.boolean().default(false),
})

const updatePostSchema = basePostSchema.partial()

const postResponseSchema = basePostSchema.extend({
  id: z.string().uuid(),
  createdAt: z.string(),
  updatedAt: z.string(),
  published: z.boolean(),
})

TypeScript Type Inference

Extract TypeScript types from Zod schemas:
const userSchema = z.object({
  id: z.string(),
  email: z.string().email(),
  name: z.string(),
})

type User = z.infer<typeof userSchema>

Custom Validation Rules

Add custom validation logic:
const passwordSchema = z
  .string()
  .min(8, "Password must be at least 8 characters")
  .regex(/[A-Z]/, "Password must contain an uppercase letter")
  .regex(/[a-z]/, "Password must contain a lowercase letter")
  .regex(/[0-9]/, "Password must contain a number")

const signUpSchema = z.object({
  email: z.string().email(),
  password: passwordSchema,
  confirmPassword: z.string(),
}).refine((data) => data.password === data.confirmPassword, {
  message: "Passwords don't match",
  path: ["confirmPassword"],
})

Documenting Full Routes

Complete example with validation and documentation:
app.post(
  "/posts",
  describeRoute({
    tags: ["Posts"],
    description: "Create a new post",
    responses: {
      200: {
        description: "Post created successfully",
        content: {
          "application/json": {
            schema: resolver(z.object({ data: postResponseSchema })),
          },
        },
      },
      400: {
        description: "Validation error",
        content: {
          "application/json": {
            schema: resolver(
              z.object({
                error: z.object({
                  code: z.literal("VALIDATION_ERROR"),
                  message: z.string(),
                  issues: z.array(z.any()),
                }),
              }),
            ),
          },
        },
      },
    },
  }),
  zValidator("json", createPostSchema),
  async (c) => {
    const data = c.req.valid("json")
    const post = await db.insert(posts).values(data).returning().get()
    return c.json({ data: post })
  },
)

Next Steps

  • Routing - Learn about route structure
  • Middleware - Add authentication and rate limiting