Skip to main content
react

Zod, Valibot, and the UX of Runtime Validation

Comparing Zod and Valibot beyond bundle size: how their error formatting, transform APIs, and philosophy affect the user's experience of your form errors.


5 min read

Zod and Valibot solve the same problem with different priorities. Zod has the larger ecosystem, rich error objects, and over two years of production usage across Next.js apps. Valibot is tree-shakeable by design — each validator is a separate function, so bundlers can eliminate the ones you do not use. For a schema with five fields, Valibot ships less JavaScript. For a complex schema that uses everything Zod offers, the difference narrows. Pick based on your bundle constraints and whether you need the Zod ecosystem integrations.


The Core APIs Side by Side

The same schema in both libraries:

// Zod
import { z } from "zod";

const signUpSchema = z.object({
  email: z.string().email("Enter a valid email address"),
  password: z
    .string()
    .min(8, "Password must be at least 8 characters")
    .regex(/[A-Z]/, "Include at least one uppercase letter"),
  age: z
    .string()
    .transform(Number)
    .pipe(z.number().min(18, "Must be 18 or older")),
});

type SignUpData = z.infer<typeof signUpSchema>;
// Valibot
import * as v from "valibot";

const signUpSchema = v.object({
  email: v.pipe(v.string(), v.email("Enter a valid email address")),
  password: v.pipe(
    v.string(),
    v.minLength(8, "Password must be at least 8 characters"),
    v.regex(/[A-Z]/, "Include at least one uppercase letter")
  ),
  age: v.pipe(
    v.string(),
    v.transform(Number),
    v.number(),
    v.minValue(18, "Must be 18 or older")
  ),
});

type SignUpData = v.InferOutput<typeof signUpSchema>;

Zod chains methods on a schema instance. Valibot pipes validators through a v.pipe() function. Valibot's approach is what enables tree-shaking — v.email and v.minLength are discrete functions that bundlers can eliminate if unused. Zod's chaining makes it harder to shake unused validators.

The UX of Error Messages

Validation libraries do not ship your error messages to users directly — you format them first. This is where the two libraries diverge in practice.

Zod's ZodError has a flatten() method that produces a clean object with field-level errors and form-level errors:

const result = signUpSchema.safeParse(formData);

if (!result.success) {
  const { fieldErrors, formErrors } = result.error.flatten();
  // fieldErrors: { email?: string[], password?: string[] }
  // formErrors: string[]
}

fieldErrors gives you an array per field (multiple validators can fail). The common pattern is to take fieldErrors.email?.[0] — the first error — and display it under the email input. Showing all errors at once tends to overwhelm users; first-error-wins is the better UX for short schemas.

Valibot's flatten() from valibot gives the same structure:

import { flatten, safeParse } from "valibot";

const result = safeParse(signUpSchema, formData);

if (!result.success) {
  const { nested } = flatten(result.issues);
  // nested: { email?: string[], password?: string[] }
}

The structure is similar enough that switching between them in a form library resolver is straightforward.

Parse, Don't Validate

The more important concept than library choice is the philosophy: parse, don't validate. Validating means checking that input satisfies conditions and returning true/false. Parsing means running the same checks but returning a transformed, typed value — or throwing.

The age field in the examples above demonstrates this. The input is a string (HTML inputs always yield strings). The schema transforms it to a number and validates the number — so the output type is number, not string. The schema is a transformer, not just a checker.

// Validate: checks, returns boolean
function isValidAge(value: string): boolean {
  return Number(value) >= 18;
}

// Parse: transforms and types
const ageSchema = z
  .string()
  .transform(Number)
  .pipe(z.number().min(18));

const age = ageSchema.parse("24"); // type: number

The parsed result is typed correctly downstream. You do not carry string through your application logic and convert it manually. The schema boundary is where the conversion happens, once.

Ecosystem and Integrations

Zod's ecosystem advantage is real. React Hook Form's @hookform/resolvers/zod is the most commonly used integration. tRPC uses Zod for procedure input validation. Prisma's Zod generator creates schemas from your database model. If you are in a Next.js + tRPC or Prisma stack, reaching for Zod is the path of least resistance.

Valibot has resolvers for React Hook Form too (@hookform/resolvers/valibot), and the API compatibility means swapping is mostly mechanical. The meaningful reason to choose Valibot is bundle size in a context where it matters — a public-facing form where you are optimizing the initial payload aggressively.

The Practical Choice

For most product work: Zod. The ecosystem integrations save real time, the error API is well-documented, and the bundle cost (around 14kB gzipped) is acceptable in an application already loading React and a router.

For a library you are shipping to others, or a highly optimized public page: Valibot. The tree-shaking story means the consumer pays only for the validators they use. That is a meaningful advantage when you control neither the schema complexity nor the bundle budget.

Both libraries implement the same underlying idea: define a schema once, get TypeScript types and runtime validation from the same source. That idea is the important one — whichever library implements it for your constraints is the right choice.