Valibot’s appeal for forms is bundle size: its API is made of standalone functions, so bundlers include only the validators a form actually uses, and a signup form’s validation can shrink to a small fraction of what a method-chaining library ships. The price is a different API shape — pipe(string(), email()) instead of z.string().email() — and a different way of attaching cross-field errors.

This page, part of choosing a schema validation library, maps the Zod constructs forms depend on to their Valibot equivalents, shows how to place cross-field issues on the right field with forward, and covers the form-library side of the switch.


Context and prerequisites

The structural differences:

  • Schemas and actions are functions. string(), number(), object({...}) create schemas; email(), minLength(1), trim() are actions combined with pipe(schema, ...actions).
  • Tree-shaking is per function. Unused validators are not in the bundle. With Zod’s methods on a prototype, bundlers cannot remove unused ones as easily.
  • Parsing uses parse(schema, input) or safeParse(schema, input), returning { success, output, issues }.
  • Errors: issues is an array; flatten(issues) groups them into { root, nested } keyed by dot path — convenient for forms.
  • Cross-field checks use check or partialCheck actions on the object pipe, and forward to move the resulting issue to a specific field path.
  • Transforms are the transform action inside a pipe; coercion is explicit.
Zod constructs and Valibot equivalents z.string().min(1) with a message maps to pipe of string with minLength one and the message. z.string().email() maps to pipe of string with email. z.number().int() maps to pipe of number with integer. z.enum maps to picklist. .optional() maps to optional wrapping the schema. superRefine on an object with an issue on a path maps to pipe of object with forward of partialCheck. safeParse maps to safeParse with the schema as the first argument. z.infer maps to InferOutput. Zod Valibot z.string().min(1, m) pipe(string(), minLength(1, m)) z.string().email(m) pipe(string(), email(m)) z.number().int() pipe(number(), integer()) z.enum(["a", "b"]) picklist(["a", "b"]) x.optional() optional(x) obj.superRefine(→ path) pipe(obj, forward(partialCheck(…), ["path"])) z.infer<typeof S> InferOutput<typeof S>

The core pattern: the same signup schema, translated

// Zod version (for reference)
import { z } from "zod";
export const SignupZ = z.object({
  email: z.string().trim().min(1, "Enter your email address.").email("Enter an email like [email protected]."),
  password: z.string().min(12, "Use at least 12 characters."),
  confirm: z.string().min(1, "Re-enter your password."),
  plan: z.enum(["free", "pro"], { errorMap: () => ({ message: "Choose a plan." }) }),
}).superRefine((v, ctx) => {
  if (v.password !== v.confirm) ctx.addIssue({ code: "custom", path: ["confirm"], message: "Passwords do not match." });
});
// Valibot version
import * as v from "valibot";

export const SignupV = v.pipe(
  v.object({
    email: v.pipe(v.string(), v.trim(), v.minLength(1, "Enter your email address."), v.email("Enter an email like [email protected].")),
    password: v.pipe(v.string(), v.minLength(12, "Use at least 12 characters.")),
    confirm: v.pipe(v.string(), v.minLength(1, "Re-enter your password.")),
    plan: v.picklist(["free", "pro"], "Choose a plan."),
  }),
  // partialCheck runs only when the listed fields are themselves valid, so a
  // short password does not ALSO report "do not match". forward() moves the
  // issue from the object root to the confirm field.
  v.forward(
    v.partialCheck([["password"], ["confirm"]], (i) => i.password === i.confirm, "Passwords do not match."),
    ["confirm"],
  ),
);

export type Signup = v.InferOutput<typeof SignupV>;

// Form-friendly errors: one message per field path.
export function formErrors(input: unknown): Record<string, string> {
  const r = v.safeParse(SignupV, input);
  if (r.success) return {};
  const flat = v.flatten<typeof SignupV>(r.issues);
  return Object.fromEntries(Object.entries(flat.nested ?? {}).map(([k, msgs]) => [k, msgs![0]]));
}
// React Hook Form: swap the resolver; everything else is unchanged.
import { valibotResolver } from "@hookform/resolvers/valibot";
// useForm({ resolver: valibotResolver(SignupV) })

Step-by-step walkthrough

  1. Translate leaf schemas first. Each z.x().a().b() chain becomes pipe(x(), a(), b()), keeping argument order and messages.
  2. Replace .optional(), .nullable() and .default() with wrappers. optional(schema, default) combines optionality and a default.
  3. Translate cross-field refinements to forward(partialCheck(...)). partialCheck lists the fields it reads and skips when they are invalid; forward places the issue on the field the user should change — the same placement principle as how to validate dependent fields with Zod.
  4. Use flatten for form errors. flatten(issues).nested gives Record<path, string[]>, matching what most form UIs want.
  5. Swap the resolver or adapter. @hookform/resolvers/valibot, VeeValidate’s Valibot adapter, Superforms’ valibot adapter — or Standard Schema adapters that accept either library, per standard schema for library-agnostic forms.
  6. Measure the bundle before and after. Confirm the saving with your bundler’s analyser; it depends on how many validators the app uses overall.

Why partialCheck matters for forms

A plain check on the object runs the comparison even when one of the passwords is itself invalid, so a user who typed a short password sees both “Use at least 12 characters” and “Passwords do not match” — two messages about one mistake. partialCheck declares which fields the rule depends on and runs only when those fields passed their own validation, which gives the ordering users expect: fix the password first, then the match is checked. It is the Valibot expression of “most basic problem first”, the same rule that composing pure validator functions implements with first.

Why Valibot can be smaller — what gets bundled A conceptual comparison of the validators a small signup form needs. The form uses about eight validation functions. With Valibot's function-based API, roughly those eight functions and their shared helpers are bundled. With a method-chaining API, the schema classes carry every method, so a much larger share of the library ships regardless of use. Exact sizes depend on versions and on everything else the app imports; measure with a bundle analyser. functions the form uses ~8 functions Valibot: bundled used + helpers method-chaining: bundled most of the classes A conceptual illustration, not measured byte sizes: see the bundle-size comparison page for measurements.

Reading pipes as a sequence

A useful mental model for reviewing translated schemas: a Valibot pipe is a list executed in order, and each action sees the output of the previous one. pipe(string(), trim(), minLength(1), email()) checks that the value is a string, trims it, requires something to be left, then checks the email shape — in exactly that order. Zod chains behave similarly, but the order is less visible because some methods are checks and others are transforms attached to the same object. When porting, write each pipe in the order you want users to meet problems: type, normalisation, presence, format, business rules. Reviewers can then read the schema top to bottom as the sequence of questions the form asks about a value.


Failure modes and edge cases

1. Issue paths as objects

Valibot issues carry path as an array of path items (objects with key and more). Use getDotPath(issue) or flatten rather than reading issue.path as strings.

2. check without forward

A cross-field check on the object produces an issue at the object root, which form libraries do not render next to any field. Always wrap with forward to the relevant field.

3. Async validation

Async actions require pipeAsync, objectAsync and parseAsync/safeParseAsync. Mixing sync and async variants is a type error; make sure your resolver runs the async parse when the schema is async.

4. Coercion

Valibot does not coerce. Form strings need explicit transforms (pipe(string(), transform(Number), number()) or a preprocessing step), with the same empty-string care as in Zod.

5. Messages and i18n

Valibot supports global message configuration and official translations via @valibot/i18n. Port your Zod error map to Valibot’s setGlobalMessage / setSpecificMessage, keeping the same catalogue of user-facing messages.

What changes in the codebase Schema definitions change from method chains to pipe calls with the same messages. Cross-field rules change from superRefine with addIssue to forward wrapping partialCheck, which also avoids duplicate messages. Form library integration changes only the resolver or adapter import; components, field wiring and error rendering stay the same. Schemas Chains → pipe(…). Messages unchanged. Cross-field rules superRefine → forward(partialCheck). Fewer duplicate messages. Form integration Swap the resolver import. Components unchanged.

Verification checklist


Frequently Asked Questions

Is Valibot always smaller than Zod?

For typical form schemas, it is usually much smaller because only used functions are bundled. Zod 4 introduced a smaller “mini” variant with a functional API aimed at the same goal. Measure your app, because the saving depends on how much validation code is shared across routes.

Can I migrate one form at a time?

Yes. The libraries are independent, and resolvers are per form. During the transition both are in the bundle, so finish the migration once started.

How do I share Valibot schemas with the server?

The same way as Zod: a dependency-free module imported by both sides, as in sharing one Zod schema between client and server. Valibot runs on Node, Deno, Bun and edge runtimes.


Related

← Choosing a Schema Validation Library