Skip to main content
Semola provides automatic request and response validation using Standard Schema-compatible libraries like Zod, Valibot, and ArkType.

Standard Schema Support

Semola uses the Standard Schema specification, which provides a unified interface for validation libraries. This means you can use your preferred validation library:
  • Zod - Most popular, TypeScript-first
  • Valibot - Modular and lightweight
  • ArkType - Fast with great type inference
  • Any other Standard Schema v1 compatible library

Request Validation

All request fields are validated before reaching your handler. If validation fails, the API returns a 400 Bad Request with detailed error messages.

Validation Fields

  • body: JSON request body (validates Content-Type)
  • params: Path parameters (e.g., /users/:id)
  • query: Query string (supports arrays like ?tags=a&tags=b)
  • headers: HTTP headers
  • cookies: Parsed from Cookie header

Basic Example

import { Api } from "semola/api";
import { z } from "zod";

const api = new Api();

api.defineRoute({
  path: "/users/:id",
  method: "GET",
  request: {
    params: z.object({
      id: z.uuid(),
    }),
    query: z.object({
      includeProfile: z.coerce.boolean().optional(),
    }),
    headers: z.object({
      authorization: z.string(),
    }),
  },
  response: {
    200: z.object({
      id: z.string(),
      name: z.string(),
    }),
  },
  handler: async (c) => {
    // All fields are validated and typed
    const userId = c.req.params.id;              // string (validated UUID)
    const includeProfile = c.req.query.includeProfile; // boolean | undefined
    const auth = c.req.headers.authorization;    // string

    const user = await getUser(userId, includeProfile);
    return c.json(200, user);
  },
});

Body Validation

Validates JSON request bodies:
api.defineRoute({
  path: "/users",
  method: "POST",
  request: {
    body: z.object({
      name: z.string().min(1),
      email: z.email(),
      age: z.number().min(0).optional(),
    }),
  },
  response: {
    201: z.object({
      id: z.string(),
      name: z.string(),
      email: z.string(),
    }),
  },
  handler: async (c) => {
    // c.req.body is typed as { name: string; email: string; age?: number }
    const user = await createUser(c.req.body);
    return c.json(201, user);
  },
});
Requirements:
  • Content-Type must be application/json
  • Body must be valid JSON
  • Data must match the schema

Query String Validation

Validates URL query parameters:
api.defineRoute({
  path: "/users",
  method: "GET",
  request: {
    query: z.object({
      page: z.coerce.number().optional(),
      limit: z.coerce.number().optional(),
      sortBy: z.enum(["name", "createdAt"]).optional(),
      tags: z.array(z.string()).optional(),
    }),
  },
  response: {
    200: z.object({
      users: z.array(UserSchema),
      total: z.number(),
    }),
  },
  handler: async (c) => {
    const { page = 1, limit = 10, sortBy = "createdAt", tags } = c.req.query;
    
    const { users, total } = await listUsers({ page, limit, sortBy, tags });
    return c.json(200, { users, total });
  },
});
Features:
  • Supports array values (e.g., ?tags=a&tags=b becomes ["a", "b"])
  • Use z.coerce.number() to parse string values as numbers
  • All query values are initially strings

Path Parameters

Validates dynamic path segments:
api.defineRoute({
  path: "/users/:userId/posts/:postId",
  method: "GET",
  request: {
    params: z.object({
      userId: z.uuid(),
      postId: z.coerce.number(),
    }),
  },
  response: {
    200: PostSchema,
  },
  handler: async (c) => {
    // c.req.params.userId is typed as string (validated UUID)
    // c.req.params.postId is typed as number
    const post = await getPost(c.req.params.userId, c.req.params.postId);
    return c.json(200, post);
  },
});

Headers Validation

Validates HTTP headers:
api.defineRoute({
  path: "/data",
  method: "GET",
  request: {
    headers: z.object({
      authorization: z.string(),
      "x-api-key": z.string(),
      "accept-language": z.string().optional(),
    }),
  },
  response: {
    200: DataSchema,
  },
  handler: async (c) => {
    const token = c.req.headers.authorization;
    const apiKey = c.req.headers["x-api-key"];
    const lang = c.req.headers["accept-language"];

    const data = await getData(token, apiKey, lang);
    return c.json(200, data);
  },
});

Cookies Validation

Validates cookies using Bun’s native CookieMap:
api.defineRoute({
  path: "/profile",
  method: "GET",
  request: {
    cookies: z.object({
      session: z.string(),
      preferences: z.string().optional(),
    }),
  },
  response: {
    200: UserSchema,
  },
  handler: async (c) => {
    const sessionId = c.req.cookies.session;
    const user = await getUserFromSession(sessionId);
    return c.json(200, user);
  },
});

Response Validation

When output validation is enabled (the default), the response produced by your handler is validated against the response schema you define on the route.

Basic Response Validation

api.defineRoute({
  path: "/users/:id",
  method: "GET",
  response: {
    200: z.object({ 
      id: z.string(), 
      name: z.string() 
    }),
    404: z.object({ 
      message: z.string() 
    }),
  },
  handler: async (c) => {
    const user = await getUser(c.req.params.id);

    if (!user) {
      return c.json(404, { message: "User not found" });
    }

    // If `user` doesn't match the schema, a 400 is returned automatically
    return c.json(200, user);
  },
});

Validation Behavior

  • Output validation only runs when a response schema is defined
  • Routes without a response schema are unaffected
  • If validation fails, the framework returns 400 Bad Request with error details
  • This catches bugs where your handler returns data that doesn’t match the declared contract

Validation Configuration

You can control input and output validation independently via the validation option on the Api constructor.

Configuration Options

ValueInput validationOutput validation
true (default)
false
{ input: true, output: true }
{ input: false }
{ output: false }

Default (All Validation Enabled)

const api = new Api();
// Equivalent to:
const api = new Api({ validation: true });
// Or:
const api = new Api({ validation: { input: true, output: true } });

Disable All Validation

Useful for performance-critical internal services:
const api = new Api({ validation: false });
This disables both input and output validation, maximizing performance but removing type safety guarantees at runtime.

Disable Only Input Validation

const api = new Api({
  validation: { input: false },
});
Use case: When you trust incoming data (e.g., internal microservices) but still want to validate your handler responses.

Disable Only Output Validation

const api = new Api({
  validation: { output: false },
});
Use case: When you’re confident your handlers return correct data and want to skip the validation overhead for better performance.

Per-Environment Configuration

const api = new Api({
  validation: {
    input: true,
    output: process.env.NODE_ENV === "development", // Only in dev
  },
});

Validation with Different Libraries

import { z } from "zod";
import { Api } from "semola/api";

const UserSchema = z.object({
  id: z.string().uuid(),
  name: z.string().min(1),
  email: z.email(),
  age: z.number().min(0).optional(),
});

const api = new Api();

api.defineRoute({
  path: "/users",
  method: "POST",
  request: {
    body: UserSchema.omit({ id: true }),
  },
  response: {
    201: UserSchema,
  },
  handler: async (c) => {
    const user = await createUser(c.req.body);
    return c.json(201, user);
  },
});

Valibot

import * as v from "valibot";
import { Api } from "semola/api";

const UserSchema = v.object({
  id: v.pipe(v.string(), v.uuid()),
  name: v.pipe(v.string(), v.minLength(1)),
  email: v.pipe(v.string(), v.email()),
  age: v.optional(v.pipe(v.number(), v.minValue(0))),
});

const api = new Api();

api.defineRoute({
  path: "/users",
  method: "POST",
  request: {
    body: v.omit(UserSchema, ["id"]),
  },
  response: {
    201: UserSchema,
  },
  handler: async (c) => {
    const user = await createUser(c.req.body);
    return c.json(201, user);
  },
});

ArkType

import { type } from "arktype";
import { Api } from "semola/api";

const UserSchema = type({
  id: "string.uuid",
  name: "string>0",
  email: "string.email",
  "age?": "number>=0",
});

const api = new Api();

api.defineRoute({
  path: "/users",
  method: "POST",
  request: {
    body: UserSchema.omit("id"),
  },
  response: {
    201: UserSchema,
  },
  handler: async (c) => {
    const user = await createUser(c.req.body);
    return c.json(201, user);
  },
});

Error Handling

Validation Error Response

When validation fails, Semola returns a 400 Bad Request with error details:
{
  "message": "name: String must contain at least 1 character(s), email: Invalid email"
}
The error message includes:
  • Field path (e.g., name, query.page, headers.authorization)
  • Validation error message from the schema library

Custom Error Messages

Use your validation library’s features to customize error messages: With Zod:
const schema = z.object({
  email: z.email({ message: "Please provide a valid email address" }),
  age: z.number().min(18, { message: "You must be at least 18 years old" }),
});
With Valibot:
const schema = v.object({
  email: v.pipe(
    v.string(),
    v.email("Please provide a valid email address")
  ),
  age: v.pipe(
    v.number(),
    v.minValue(18, "You must be at least 18 years old")
  ),
});

Body Caching

Semola implements body caching to prevent re-parsing JSON when multiple middlewares validate the same body:
const middleware1 = new Middleware({
  request: {
    body: z.object({ userId: z.string() }),
  },
  handler: async (c) => {
    // Body parsed once
    return { user: await getUser(c.req.body.userId) };
  },
});

const middleware2 = new Middleware({
  request: {
    body: z.object({ action: z.string() }),
  },
  handler: async (c) => {
    // Body reused from cache, not re-parsed
    return { action: c.req.body.action };
  },
});

api.defineRoute({
  path: "/action",
  method: "POST",
  middlewares: [middleware1, middleware2] as const,
  request: {
    body: z.object({
      userId: z.string(),
      action: z.string(),
      data: z.record(z.unknown()),
    }),
  },
  handler: async (c) => {
    // Body validated against combined schema, no re-parsing
    return c.json(200, { success: true });
  },
});
This optimization is automatic and requires no configuration.

Best Practices

  1. Use specific validation rules - Don’t just validate types, add constraints (min/max, regex, etc.)
  2. Validate early - Let validation catch errors before expensive operations
  3. Provide clear error messages - Help users understand what went wrong
  4. Use .optional() judiciously - Make fields required unless there’s a good reason
  5. Coerce query/param types - Use z.coerce.number() for numeric query parameters
  6. Disable validation selectively - Only disable validation when you have a specific performance need
  7. Keep output validation enabled in development - Catches bugs where handlers return incorrect data
  8. Use Standard Schema - Benefits from improved type inference and library flexibility

Build docs developers (and LLMs) love