Skip to content

Search docs

Jump between documentation pages.

Browse docs

Validation with Zod

Zod is the default validator most DaloyJS apps reach for: chainable schemas, mature ecosystem, and a huge community. Zod implements Standard Schema, so DaloyJS picks it up without any adapter.

Prefer a more modular, tree-shakeable API? See Validation with Valibot. Both work the same way at the framework level.

One Zod schema, picked up everywhere
  1. you writeZod schemaz.object({ ... })
  2. runtimeRequest & response validation422 on invalid input
  3. compile timeInferred handler typesz.infer<typeof ...>
  4. specOpenAPI JSON Schemastays in sync via pnpm gen
Because Zod implements Standard Schema, DaloyJS reuses one schema for runtime validation, handler type inference, and the OpenAPI document. No adapter, no second source of truth.

Install

ts
pnpm add @daloyjs/core zod

What gets validated

For each route you can declare schemas for:

  • request.params: decoded path parameters. They start as strings, so use z.coerce.number(), z.uuid(), or enums when you need stronger shapes.
  • request.query: query string values. Repeated keys become arrays before validation.
  • request.headers: request headers as lower-case names.
  • request.body: parsed request bodies. The body is only read when declared.
  • responses[status].body: typed and validated responses.

A complete route

ts
import { App } from "@daloyjs/core";
import { z } from "zod";

const CreateOrder = z.object({
  sku: z.string().min(1),
  qty: z.coerce.number().int().positive(),
});

const Order = z.object({
  id: z.uuid(),
  tenantId: z.string(),
  sku: z.string(),
  qty: z.number().int().positive(),
  dryRun: z.boolean(),
});

export const app = new App().route({
  method: "POST",
  path: "/orders",
  operationId: "createOrder",
  request: {
    query: z
      .object({
        dryRun: z
          .enum(["true", "false"])
          .transform((value) => value === "true")
          .optional(),
      })
      .optional(),
    headers: z.object({ "x-tenant": z.string().min(1) }),
    body: CreateOrder,
  },
  responses: {
    201: { description: "Created", body: Order },
    422: { description: "Validation failed" },
  },
  handler: async ({ query, headers, body }) => {
    const tenantId = headers["x-tenant"];
    const dryRun = query?.dryRun ?? false;

    return {
      status: 201,
      body: {
        id: crypto.randomUUID(),
        tenantId,
        sku: body.sku,
        qty: body.qty,
        dryRun,
      },
    };
  },
});

On invalid input, DaloyJS returns 422 Unprocessable Entity as RFC 9457 problem+json with an errors array of per-issue path and message records.

Zod coercion for strings

Path params, query values, headers, and urlencoded form values arrive as strings before schema validation. Use Zod coercion or transforms when you want numbers, booleans, or dates in the handler.

ts
const PageQuery = z.object({
  page: z.coerce.number().int().min(1).default(1),
  pageSize: z.coerce.number().int().min(1).max(100).default(20),
  published: z
    .enum(["true", "false"])
    .transform((value) => value === "true")
    .optional(),
});

Body limits and content types

When a route declares request.body, DaloyJS will also enforce:

  • Content-Length and streamed size against app.bodyLimitBytes413.
  • Content-Type against the route's accepts list, or global allowedContentTypes if set → 415.
  • Default accepted body types: application/json, application/x-www-form-urlencoded, and multipart/form-data.
  • Prototype-pollution-safe parsing for JSON, query strings, urlencoded forms, and multipart forms.

JSON bodies validate as parsed JSON. Urlencoded bodies validate as an object built from URLSearchParams. Multipart bodies validate as an object built from Request.formData(). For a custom text media type, opt in with accepts and validate a z.string() body.

ts
app.route({
  method: "POST",
  path: "/legacy-form",
  operationId: "legacyForm",
  accepts: ["application/x-www-form-urlencoded"],
  request: {
    body: z.object({
      email: z.email(),
      qty: z.coerce.number().int().positive(),
    }),
  },
  responses: {
    200: { description: "ok", body: z.object({ ok: z.boolean() }) },
  },
  handler: async ({ body }) => ({ status: 200, body: { ok: body.qty > 0 } }),
});

Response validation

When a response schema is declared, DaloyJS validates the handler return before serializing it. Zod object schemas strip unknown keys by default, so the validated value also prevents undeclared fields from leaking to clients. Use z.looseObject() only when extra keys are part of the intended response contract.

ts
const PublicUser = z.object({
  id: z.string(),
  email: z.email(),
});

app.route({
  method: "GET",
  path: "/me",
  operationId: "me",
  responses: {
    200: { description: "Current user", body: PublicUser },
  },
  handler: async () => ({
    status: 200,
    // passwordHash is stripped before serialization.
    body: { id: "u_1", email: "dev@example.com", passwordHash: "secret" },
  }),
});

Type inference

The handler context is fully typed: body, params, query, and headers are inferred from your schemas. The return value is also typed; TypeScript reports an error if you return a status not declared in responses.

ts
import { z } from "zod";

const Book = z.object({
  id: z.uuid(),
  title: z.string(),
  author: z.string(),
});

export type Book = z.infer<typeof Book>;

See also