Valibot Form Validation

How do you get schema-based form validation — typed output, composable rules, cross-field checks, structured errors — for roughly a kilobyte of JavaScript instead of a dozen? Valibot’s functional, pipe-based API lets bundlers drop every validator you do not import, which makes it a strong fit for small, performance-sensitive forms such as sign-ups, newsletter boxes and checkout steps. This recipe builds a sign-up schema with pipe and actions, coerces FormData strings, adds a password confirmation with forward and partialCheck, flattens issues into field-keyed messages, and delivers them through setCustomValidity() and reportValidity() from the Constraint Validation API.

When to Choose Valibot for a Form

Valibot is the right choice when schema validation is worth having but its cost must be minimal. Choose it when:

  • The form is on a performance-critical page — landing pages, checkout — where every kilobyte of JavaScript affects conversion.
  • You are starting fresh, with no existing Zod or Yup schemas to share.
  • You want Standard Schema compatibility, so form libraries and your own adapters accept Valibot schemas directly.

If your server and other forms already use Zod, the ecosystem benefit of one library usually outweighs Valibot’s size advantage; Zod 4’s Mini variant narrows the gap. The trade-offs are laid out in alternative schema libraries.

A Valibot field pipe A raw FormData string flows through a Valibot pipe of schema, transformation and validation actions, producing either a typed value or an issue with a message. Raw value " Ada@Example.com " v.string() type check v.trim() transformation v.email(msg) validation action Output "Ada@Example. com" or issue
Each pipe step is a separately imported function, so the bundle contains only the actions this form actually uses.

Minimal Working Valibot Form

import * as v from "valibot";

const EMAIL = "Enter an email address like name@example.com.";

export const SignupSchema = v.pipe(
  v.object({
    email: v.pipe(v.string(), v.trim(), v.nonEmpty("Enter your email address."), v.email(EMAIL)),
    password: v.pipe(v.string(), v.minLength(12, "Password must be at least 12 characters."), v.maxLength(128, "Password must be 128 characters or fewer.")),
    confirm: v.string(),
    age: v.pipe(
      v.string(),
      v.nonEmpty("Enter your age."),
      v.transform(Number),
      v.number("Age must be a number."),
      v.integer("Age must be a whole number."),
      v.minValue(16, "You must be 16 or older."),
    ),
    newsletter: v.optional(v.pipe(v.literal("on"), v.transform(() => true)), undefined),
  }),
  // Cross-field: report the mismatch on the confirm field, only when both fields individually passed.
  v.forward(
    v.partialCheck(
      [["password"], ["confirm"]],
      (input) => input.password === input.confirm,
      "Passwords don't match.",
    ),
    ["confirm"],
  ),
);

export type SignupInput = v.InferInput<typeof SignupSchema>;
export type Signup = v.InferOutput<typeof SignupSchema>;

// Flatten issues into { fieldName: firstMessage }
export function fieldErrors(issues: v.BaseIssue<unknown>[]): Record<string, string> {
  const flat = v.flatten<typeof SignupSchema>(issues as [v.BaseIssue<unknown>, ...v.BaseIssue<unknown>[]]);
  return Object.fromEntries(Object.entries(flat.nested ?? {}).map(([k, msgs]) => [k, (msgs as string[])[0]]));
}

// Wiring to the canonical novalidate + reportValidity() baseline
const form = document.querySelector<HTMLFormElement>("#signup")!;

function applyErrors(errors: Record<string, string>): void {
  for (const el of form.querySelectorAll<HTMLInputElement>("input[name]")) {
    el.setCustomValidity(errors[el.name] ?? "");
    el.toggleAttribute("aria-invalid", Boolean(errors[el.name]));
  }
}

form.addEventListener("submit", (event) => {
  const result = v.safeParse(SignupSchema, Object.fromEntries(new FormData(form)));
  applyErrors(result.success ? {} : fieldErrors(result.issues));
  if (!form.reportValidity()) event.preventDefault();
});

// Live re-check of a single field once it has an error, using the field's own schema.
form.addEventListener("input", (event) => {
  const el = event.target as HTMLInputElement;
  if (!el.hasAttribute("aria-invalid")) return;
  const fieldSchema = SignupSchema.pipe[0].entries[el.name as keyof SignupInput];
  if (!fieldSchema) return;
  const r = v.safeParse(fieldSchema, el.value);
  el.setCustomValidity(r.success ? "" : r.issues[0].message);
  if (r.success) el.removeAttribute("aria-invalid");
});

partialCheck is the key cross-field tool: it runs only when the listed paths themselves are valid, so the user never sees “Passwords don’t match” while the password is still too short. forward then attaches the issue to the confirm field’s path, so it lands on the input the user needs to change — the same outcome as cross-field password confirmation logic achieves by hand.

Cross-field check with partialCheck and forward The object schema validates each field, partialCheck runs only when password and confirm both passed, and forward attaches the mismatch issue to the confirm field's path. Object schema partialCheck forward Form password + confirm both valid input.password !== input.confirm issue "Passwords don't match." path ["confirm"] confirm.setCustomValidity( ...) → reportValidity
The mismatch message waits until both fields are individually valid, then appears on the confirmation input.

Valibot Option Reference

API Kind Purpose Notes
v.object({...}) schema Object with known keys Strips unknown keys by default
v.pipe(schema, ...actions) combinator Chain transformations and checks Runs in order; stops at the first failing validation by default
v.trim() / v.transform(fn) transformation Normalise values Put first in the pipe
v.nonEmpty(msg) validation Reject "" Needed because form fields send empty strings
v.email(msg) / v.minLength(n, msg) validation Field rules Each imported separately
v.partialCheck(paths, fn, msg) validation Cross-field rule Runs only if the listed paths are valid
v.forward(action, path) combinator Move an issue to a field path Places cross-field errors on the right input
v.safeParse(schema, input) function Parse without throwing { success, output, issues }
v.flatten(issues) function { root, nested } messages Nested keys are dotted paths

Because every action is a separate import, the bundle contains exactly the set above — which is why a form like this adds so little. Importing * as v does not defeat tree-shaking with modern bundlers, since the namespace is analysed statically.

Verification Steps

import { describe, it, expect } from "vitest";
import * as v from "valibot";
import { SignupSchema, fieldErrors } from "./signup";

const base = { email: "ada@example.com", password: "correct horse battery", confirm: "correct horse battery", age: "36" };

describe("SignupSchema", () => {
  it("accepts valid input and coerces age", () => {
    const r = v.safeParse(SignupSchema, base);
    expect(r.success && r.output.age).toBe(36);
  });
  it("reports the mismatch on confirm", () => {
    const r = v.safeParse(SignupSchema, { ...base, confirm: "different value!!" });
    expect(!r.success && fieldErrors(r.issues)).toEqual({ confirm: "Passwords don't match." });
  });
  it("does not report a mismatch while the password is too short", () => {
    const r = v.safeParse(SignupSchema, { ...base, password: "short", confirm: "other" });
    expect(!r.success && fieldErrors(r.issues)).toEqual({ password: "Password must be at least 12 characters." });
  });
});

Edge Cases and Failure Modes

Empty strings pass v.string(). Form fields send "" when empty, and "" is a string. Every required text field needs v.nonEmpty(msg) (or minLength(1, msg)), or it will accept blank input.

Coercing numbers. v.transform(Number) turns "" into 0, which then passes a minValue(0) rule. Put nonEmpty before the transform for required numbers, and use v.optional with a preprocessing step for optional ones.

Unchecked checkboxes. A missing key is undefined, not "off". Model checkboxes with v.optional(...) and transform "on" to true, as the schema does for newsletter.

Pipe stops at the first failure. By default a pipe stops at the first failing validation action in that field, which is usually what forms want — one message per field. If you want all messages for a field (for a requirements checklist), pass { abortPipeEarly: false } in the parse config and render the full list.

Reusing Field Schemas for Live Checks

The live re-check in the implementation reads one field’s schema out of the object (SignupSchema.pipe[0].entries[name]). That works, but it couples the form to the schema’s internal structure. A cleaner pattern is to define field schemas as named constants first and build the object from them, so live checks, the full submit check and the server all refer to the same pieces.

export const Email = v.pipe(v.string(), v.trim(), v.nonEmpty("Enter your email address."), v.email(EMAIL));
export const Password = v.pipe(v.string(), v.minLength(12, "Password must be at least 12 characters."));

export const FIELD_SCHEMAS = { email: Email, password: Password } as const;
export const SignupObject = v.object({ ...FIELD_SCHEMAS, confirm: v.string() });

Per-field checks then look up FIELD_SCHEMAS[el.name] directly, and adding a field means adding one constant. Because each constant is a separately imported set of actions, nothing extra ships to the browser. The timing of those live checks — first verdict on blur, clearing on input — follows validating on blur versus on input.

Setting Messages Globally

Writing a message on every action is the most reliable way to get good error text, but Valibot also supports a global fallback through v.setGlobalMessage and per-action defaults through v.setSpecificMessage, so a forgotten message produces something readable rather than the library’s terse default. Treat the global message as a safety net, not a substitute: a generic “Check this field” tells the user far less than a message written for the specific rule.

Using Valibot With Form Libraries

Valibot implements Standard Schema, so libraries that accept it — TanStack Form, React Hook Form’s standard resolver, VeeValidate’s adapters — take the schema as-is. With React Hook Form, valibotResolver(SignupSchema) from @hookform/resolvers/valibot maps issues onto fields, including the forwarded confirm error. If you prefer to stay library-agnostic, consume the schema through the adapter in Standard Schema: library-agnostic validation, which also lets you switch to Zod later without touching form code. On the server, the same schema validates FormData from a native post, keeping client and server rules identical as described in shared client–server schemas.

The same sign-up schema in Zod and Valibot Two columns comparing how common sign-up rules are expressed in Zod's chained API and Valibot's pipe API. Zod 3 • z.string().trim().email(msg) • z.coerce.number().int().min(16) • .superRefine() for cross-field • schema.safeParse(input) Valibot 1 • v.pipe(v.string(), v.trim(), v.email(msg)) • v.pipe(v.string(), v.transform(Number), v.integer(), v.minValue(16)) • v.forward(v.partialCheck(...), path) • v.safeParse(schema, input)
The rules are identical; Valibot expresses each as a separately imported action, which is what keeps its bundle small.

Frequently Asked Questions

Why is Valibot smaller than Zod?

Valibot uses standalone functions for every schema and action instead of methods on objects, so bundlers can remove every function a form does not import. Zod 3's method-chaining API keeps all methods on the schema objects, so more of the library ships.

How do I validate that two fields match in Valibot?

Wrap the object in a pipe and add v.forward(v.partialCheck([["password"], ["confirm"]], check, message), ["confirm"]). partialCheck runs only when both fields are valid, and forward attaches the issue to the confirm field.

Why does Valibot accept an empty required field?

Empty form fields send an empty string, which is a valid string. Add v.nonEmpty(message) to every required text field.

Can I use Valibot with React Hook Form?

Yes. Use valibotResolver from @hookform/resolvers/valibot, or the generic Standard Schema resolver, since Valibot implements the Standard Schema interface.

← Back to Alternative Schema Libraries