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 withpipe(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)orsafeParse(schema, input), returning{ success, output, issues }. - Errors:
issuesis an array;flatten(issues)groups them into{ root, nested }keyed by dot path — convenient for forms. - Cross-field checks use
checkorpartialCheckactions on the object pipe, andforwardto move the resulting issue to a specific field path. - Transforms are the
transformaction inside a pipe; coercion is explicit.
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
- Translate leaf schemas first. Each
z.x().a().b()chain becomespipe(x(), a(), b()), keeping argument order and messages. - Replace
.optional(),.nullable()and.default()with wrappers.optional(schema, default)combines optionality and a default. - Translate cross-field refinements to
forward(partialCheck(...)).partialChecklists the fields it reads and skips when they are invalid;forwardplaces the issue on the field the user should change — the same placement principle as how to validate dependent fields with Zod. - Use
flattenfor form errors.flatten(issues).nestedgivesRecord<path, string[]>, matching what most form UIs want. - Swap the resolver or adapter.
@hookform/resolvers/valibot, VeeValidate’s Valibot adapter, Superforms’valibotadapter — or Standard Schema adapters that accept either library, per standard schema for library-agnostic forms. - 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.
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.
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.