Search docs

Jump between documentation pages.

Validation with Valibot

Valibot is a modular, tree-shakeable schema library that ships as a collection of small functions instead of a chained builder. It implements Standard Schema, so DaloyJS picks it up the same way it picks up Zod — no adapter, no wrapper, no extra deps.

Valibot is developed in the open at github.com/open-circle/valibot and published to npm as valibot— that's the package you install below.

Install

ts
pnpm add @daloyjs/core valibot

Why Valibot

  • Bundle size. You import only the validators you actually use, which matters on edge runtimes and in browser-shipped contracts.
  • Functional API. v.pipe(v.string(), v.email()) instead of z.string().email(). Easier to compose, easier to lint.
  • Standard Schema native.Same handler types and the same problem+json error shape you get with Zod — DaloyJS doesn't care which one you picked.

A complete route

ts
import * as v from "valibot";
import { Daloy } from "@daloyjs/core";

const app = new Daloy();

const CreateOrder = v.object({
  sku: v.pipe(v.string(), v.minLength(1)),
  qty: v.pipe(v.number(), v.integer(), v.minValue(1)),
  notes: v.optional(v.pipe(v.string(), v.maxLength(280))),
});

const Order = v.object({
  id: v.pipe(v.string(), v.uuid()),
  sku: v.string(),
  qty: v.number(),
});

app.route({
  method: "POST",
  path: "/orders",
  operationId: "createOrder",
  request: { body: CreateOrder },
  responses: {
    201: { description: "Created", body: Order },
    422: { description: "Validation failed" },
  },
  handler: async ({ body }) => ({
    status: 201,
    body: { id: crypto.randomUUID(), sku: body.sku, qty: body.qty },
  }),
});

body in the handler is inferred from CreateOrder — including the optional notes field. Returning anything that doesn't match Order is a TypeScript error, not a runtime surprise.

Params, query, and headers

Path params and query strings arrive as strings. Drop a v.transform (or one of the built-in v.toNumber/v.toBoolean/v.toDate actions) into the pipe to convert before further validation:

ts
import * as v from "valibot";

const Params = v.object({
  id: v.pipe(v.string(), v.uuid()),
});

const Query = v.object({
  // "?page=2" -> number
  page: v.optional(
    v.pipe(v.string(), v.transform(Number), v.number(), v.integer(), v.minValue(1)),
    "1",
  ),
  // "?tag=foo&tag=bar" -> string[]
  tag: v.optional(v.array(v.string()), []),
});

const Headers = v.object({
  "x-request-id": v.optional(v.pipe(v.string(), v.uuid())),
});

app.route({
  method: "GET",
  path: "/books/:id",
  operationId: "getBook",
  request: { params: Params, query: Query, headers: Headers },
  responses: { 200: { description: "OK", body: v.object({ id: v.string() }) } },
  handler: async ({ params, query, headers }) => ({
    status: 200,
    body: { id: params.id },
  }),
});

Discriminated unions

Use v.variant for tagged unions. DaloyJS emits a proper discriminator in the OpenAPI document so generated clients get narrowing for free.

ts
import * as v from "valibot";

const Event = v.variant("type", [
  v.object({ type: v.literal("created"), id: v.string() }),
  v.object({ type: v.literal("updated"), id: v.string(), fields: v.array(v.string()) }),
  v.object({ type: v.literal("deleted"), id: v.string() }),
]);

app.route({
  method: "POST",
  path: "/events",
  operationId: "ingestEvent",
  request: { body: Event },
  responses: { 202: { description: "Accepted" } },
  handler: async ({ body }) => {
    if (body.type === "updated") {
      // body.fields is string[] here — narrowed by the discriminator.
    }
    return { status: 202 };
  },
});

Reusing types

ts
import * as v from "valibot";

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

export type Book = v.InferOutput<typeof Book>;
export type BookInput = v.InferInput<typeof Book>;

v.InferOutput mirrors Zod's z.infer. Use v.InferInput when you have transforms and need the pre-parse shape (for example, in a form library).

Errors

Validation failures produce the same response as every other validator in DaloyJS: 422 Unprocessable Entity as RFC 9457 problem+json, with each issue's path and message. You don't need to write an error handler — that's the framework's job.

ts
{
  "type": "https://daloyjs.dev/problems/validation",
  "title": "Validation failed",
  "status": 422,
  "errors": [
    { "path": ["qty"], "message": "Invalid type: Expected number but received string" }
  ]
}

OpenAPI

Valibot schemas are converted into JSON Schema by DaloyJS's OpenAPI generator the same way Zod schemas are. Run the CLI and your spec is in sync with the route definitions:

ts
pnpm daloy openapi --out openapi.json

Mixing validators

Nothing stops you from using Valibot for one route and Zod for another in the same app — both speak Standard Schema. Useful when migrating a codebase incrementally, or when a shared package already exports its schemas in one library and you don't want to rewrite them.

See also