# Validation

DaloyJS validates inputs through [Standard Schema](https://github.com/standard-schema/standard-schema), a tiny interface exposed by validators such as **Zod**, **Valibot**, **ArkType**, and TypeBox via a Standard Schema adapter. Pick the validator that fits your project. The DaloyJS contract is the same.

**Diagram: One interface, four validators**

- **Standard Schema** (source, framework contract) - the ~standard property
- **Zod** (default) - z.object({ ... })
- **Valibot** (tree-shakeable) - v.object({ ... })
- **ArkType** - type({ ... })
- **TypeBox** - via adapter

Each validator exposes the same ~standard property, so DaloyJS infers handler types, generates OpenAPI, and returns problem+json errors through one framework path.

## What gets validated

For each route you can declare schemas for:

- `request.params`: decoded path parameters. They start as strings. Coerce in your schema if you want numbers, UUIDs, or enums.
- `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 a route declares a body schema.
- `responses[status].body`: typed and validated response bodies.

Query strings, form fields, and multipart fields drop prototype pollution keys such as `__proto__`, `constructor`, and `prototype` before validation.

## Bound numeric fields (money, qty, IDs)

Bare `z.number()` accepts `1e308`, `-Infinity` after coercion mistakes, and other values that are fine for IEEE floats but disastrous for money, inventory, or pagination. Always put domain bounds on numeric request fields:

```ts
import { z } from "zod";

// Money: finite, positive, capped at a business ceiling.
const Money = z.number().finite().positive().max(1_000_000);

// Quantities: integers in a sane range.
const Qty = z.number().int().positive().max(10_000);

// Prefer coerce only when the value arrives as a string (query / form).
const Page = z.coerce.number().int().min(1).max(1000);

// Strict bodies reject mass-assignment extras.
const CreatePayment = z
  .object({ amount: Money, currency: z.enum(["USD", "EUR", "NOK"]) })
  .strict();
```

Zod 4 already rejects `NaN` / non-finite numbers for `z.number()` in many cases, but a ceiling (`.max()`) and sign constraint (`.positive()` / `.nonnegative()`) are still your refund-fraud and overflow backstop. Pair this with response schemas so sensitive fields never leak outbound.

## End-to-end example

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

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

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

export const app = new App().post(
  "/orders/:orderId/items",
  {
    operationId: "createOrderItem",
    request: {
      params: z.object({ orderId: z.uuid() }),
      query: z
        .object({
          dryRun: z
            .enum(["true", "false"])
            .transform((value) => value === "true")
            .optional(),
        })
        .optional(),
      headers: z.object({ "x-tenant": z.string().min(1) }),
      body: CreateLineItem,
    },
    responses: {
      201: { description: "Created", body: LineItem },
      422: { description: "Validation error" },
    },
  },
  async ({ params, query, headers, body }) => {
    const tenantId = headers["x-tenant"];
    const dryRun = query?.dryRun ?? false;

    return {
      status: 201,
      body: {
        id: `${params.orderId}:li_1`,
        tenantId,
        ...body,
        dryRun,
      },
    };
  },
);
```

## Pick your validator

- [Zod](/docs/validation/zod): the default for most teams. Chainable API, large ecosystem, easy to learn.
- [Valibot](/docs/validation/valibot): modular and tree-shakeable. Great for edge runtimes and browser-shipped contracts.

ArkType and TypeBox-compatible schemas also work when they expose the same `~standard` property, but DaloyJS only ships first-party docs and scaffolds for Zod and Valibot.

## Side-by-side

```ts
// Zod
import { z } from "zod";
const Body = z.object({
  sku: z.string(),
  qty: z.number().int().positive(),
});

// Valibot
import * as v from "valibot";
const Body = v.object({
  sku: v.string(),
  qty: v.pipe(v.number(), v.integer(), v.minValue(1)),
});

// ArkType
import { type } from "arktype";
const Body = type({ sku: "string", qty: "1<=number.integer" });

// TypeBox
import { Type } from "@sinclair/typebox";
const Body = Type.Object({ sku: Type.String(), qty: Type.Integer({ minimum: 1 }) });
// Wrap with your project's Standard Schema adapter before passing to DaloyJS.
```

Once a schema exposes `~standard`, DaloyJS infers handler types, generates OpenAPI, and returns problem+json errors the same way regardless of which validator produced it.

## Errors

**Diagram: Parse, then branch on the outcome**

- **Raw request** (untrusted) - params · query · headers · body
- **Schema parse** (standard schema) - validate(schema, input)
- **422 problem+json** (on failure) - errors: [{ path, message }]
- **Typed handler** (on success) - ctx.body / params / query / headers

Every declared schema runs before your handler. Invalid input never reaches handler code. It short-circuits to a 422 RFC 9457 response. Valid input arrives fully typed.

On invalid input, DaloyJS returns **422 Unprocessable Entity** as RFC 9457 problem+json with an `errors` array of per-issue `path` and `message` records. You don't write an error handler for this, it's built in. See [Errors & problem+json](/docs/errors).

## Response validation

When a response schema is declared, DaloyJS validates the handler return before serializing it. The validated value is what goes on the wire, so object schemas that strip unknown keys also prevent undeclared fields from leaking to clients. If validation fails, the client receives a redacted 500 problem response in production.

## Body limits and content types

When a route declares `request.body`, DaloyJS also enforces:

- Content-Length and streamed size against `app.bodyLimitBytes` -> **413**.
- 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 parse to objects, urlencoded bodies parse through `URLSearchParams`, and multipart bodies parse through the platform `Request.formData()` API. If you opt a route into a custom text media type, DaloyJS passes the decoded text into your body schema.

```ts
app.post(
  "/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() }) },
    },
  },
  async ({ body }) => ({ status: 200, body: { ok: body.qty > 0 } }),
);
```

## Mixing validators

You can mix and match per route. A Zod schema in one file and a Valibot schema in another are both valid, useful when migrating an existing codebase or consuming schemas from a shared package.

---

Source: https://daloyjs.dev/docs/validation