Skip to main content
Technology & EngineeringForms Validation324 lines

Valibot

Valibot — modular schema validation, tiny bundle size, parse/safeParse, pipe transformations, type inference, and comparison with Zod

Quick Summary24 lines
Valibot is a modular schema validation library that achieves extremely small bundle sizes through tree-shakable function-based design. Unlike Zod, which uses a class-based fluent API where every method is attached to every schema instance, Valibot exports independent functions that bundlers can eliminate when unused. A typical Valibot form validation adds 1-3 KB to a bundle versus 12-14 KB for Zod. Despite the different API surface, Valibot provides the same validation capabilities — type inference, transforms, refinements, async validation, and integration with form libraries. Choose Valibot when bundle size matters (e.g., client-heavy apps, edge functions) and you want the same safety guarantees as Zod.

## Key Points

- **Import as namespace** (`import * as v from "valibot"`) — this keeps usage clean and bundlers still tree-shake unused functions. Alternatively, import individual functions for explicit control.
- **Use `pipe` consistently** — even for single validations like `v.pipe(v.string(), v.email())`. This makes it easy to add more steps later without restructuring.
- **Prefer `safeParse` on the server** — just like with Zod, avoid try/catch for validation errors. The discriminated result is safer and more ergonomic.
- **Use `v.flatten`** to convert issues into a field-error map suitable for form display.
- **Use `v.InferOutput`** (not `v.InferInput`) for the type of validated data — `InferOutput` reflects transforms, while `InferInput` reflects the shape before transforms.
- **Share schemas between client and server** in a shared module for consistent validation on both sides.
- **Choose Valibot over Zod** when bundle size is a primary concern — edge functions, mobile web, or micro-frontends where every kilobyte counts.
- **Nesting `pipe` inside `pipe`** — keep pipes flat. Instead of `v.pipe(v.pipe(v.string(), v.trim()), v.email())`, write `v.pipe(v.string(), v.trim(), v.email())`.
- **Using Valibot for its own sake when Zod works** — if bundle size is not a constraint and your team already uses Zod, switching purely for ideology adds churn without benefit.
- **Forgetting `v.forward` for cross-field errors** — without `forward`, the error attaches to the root object instead of the specific field, making it invisible in form UIs.
- **Mixing `parse` and `safeParse` inconsistently** — pick one pattern per layer (e.g., `safeParse` in API handlers, `parse` in trusted internal code) and stick with it.
- **Skipping the `message` parameter on validators** — default error messages are technical ("Invalid type"). Always provide user-facing messages for any schema used in forms.

## Quick Example

```typescript
npm install valibot
```
skilldb get forms-validation-skills/ValibotFull skill: 324 lines
Paste into your CLAUDE.md or agent config

Valibot

Core Philosophy

Valibot is a modular schema validation library that achieves extremely small bundle sizes through tree-shakable function-based design. Unlike Zod, which uses a class-based fluent API where every method is attached to every schema instance, Valibot exports independent functions that bundlers can eliminate when unused. A typical Valibot form validation adds 1-3 KB to a bundle versus 12-14 KB for Zod. Despite the different API surface, Valibot provides the same validation capabilities — type inference, transforms, refinements, async validation, and integration with form libraries. Choose Valibot when bundle size matters (e.g., client-heavy apps, edge functions) and you want the same safety guarantees as Zod.

Setup

npm install valibot

Basic usage:

import * as v from "valibot";

const UserSchema = v.object({
  name: v.pipe(v.string(), v.minLength(1, "Name is required")),
  email: v.pipe(v.string(), v.email("Invalid email")),
  age: v.pipe(v.number(), v.integer(), v.minValue(13, "Must be at least 13")),
});

// Type inference — equivalent to Zod's z.infer
type User = v.InferOutput<typeof UserSchema>;
// { name: string; email: string; age: number }

// Parse (throws ValiError on failure)
const user = v.parse(UserSchema, { name: "Alice", email: "a@b.com", age: 25 });

// SafeParse (returns { success, output } | { success, issues })
const result = v.safeParse(UserSchema, unknownInput);
if (result.success) {
  console.log(result.output); // fully typed User
} else {
  console.error(result.issues);
}

Key Techniques

The Pipe Pattern

Valibot's pipe is the core composition mechanism. It chains a base type with validation actions and transforms in sequence:

// String with multiple validations
const EmailSchema = v.pipe(
  v.string("Must be a string"),
  v.trim(),                          // transform: trim whitespace
  v.toLowerCase(),                   // transform: lowercase
  v.email("Invalid email format"),   // validation
  v.maxLength(254, "Email too long") // validation
);

// Number with range
const AgeSchema = v.pipe(
  v.number("Must be a number"),
  v.integer("Must be a whole number"),
  v.minValue(0, "Cannot be negative"),
  v.maxValue(150, "Invalid age")
);

// String to number coercion (like z.coerce.number())
const NumericInputSchema = v.pipe(
  v.string(),
  v.transform((val) => Number(val)),
  v.number(),
  v.minValue(0)
);

Composing Object Schemas

const AddressSchema = v.object({
  street: v.pipe(v.string(), v.minLength(1)),
  city: v.pipe(v.string(), v.minLength(1)),
  state: v.pipe(v.string(), v.length(2)),
  zip: v.pipe(v.string(), v.regex(/^\d{5}(-\d{4})?$/)),
});

const CompanySchema = v.object({
  name: v.pipe(v.string(), v.minLength(1)),
  address: AddressSchema,
  tags: v.array(v.pipe(v.string(), v.minLength(1))),
  metadata: v.optional(v.record(v.string(), v.string())),
});

type Company = v.InferOutput<typeof CompanySchema>;

Custom Validation (check and custom)

// Simple boolean check
const EvenNumberSchema = v.pipe(
  v.number(),
  v.check((val) => val % 2 === 0, "Must be an even number")
);

// Cross-field validation on objects
const PasswordFormSchema = v.pipe(
  v.object({
    password: v.pipe(v.string(), v.minLength(8)),
    confirmPassword: v.string(),
  }),
  v.forward(
    v.check(
      (data) => data.password === data.confirmPassword,
      "Passwords do not match"
    ),
    ["confirmPassword"] // attach error to this path
  )
);

// Multiple custom checks with detailed issues
const StrongPasswordSchema = v.pipe(
  v.string(),
  v.minLength(8, "At least 8 characters"),
  v.check((val) => /[A-Z]/.test(val), "Must contain uppercase letter"),
  v.check((val) => /[0-9]/.test(val), "Must contain a digit"),
  v.check((val) => /[^a-zA-Z0-9]/.test(val), "Must contain a special character")
);

Unions and Variants

// Simple union
const StringOrNumberSchema = v.union([v.string(), v.number()]);

// Discriminated union (variant) — more efficient, better errors
const NotificationSchema = v.variant("type", [
  v.object({
    type: v.literal("email"),
    email: v.pipe(v.string(), v.email()),
    subject: v.pipe(v.string(), v.minLength(1)),
  }),
  v.object({
    type: v.literal("sms"),
    phone: v.pipe(v.string(), v.regex(/^\+\d{10,15}$/)),
    message: v.pipe(v.string(), v.maxLength(160)),
  }),
  v.object({
    type: v.literal("push"),
    deviceId: v.pipe(v.string(), v.uuid()),
    title: v.string(),
  }),
]);

type Notification = v.InferOutput<typeof NotificationSchema>;

Transforms and Coercion

// Transform output type
const DateStringSchema = v.pipe(
  v.string(),
  v.isoDate("Must be ISO date format"),
  v.transform((val) => new Date(val))
);
type DateOutput = v.InferOutput<typeof DateStringSchema>; // Date

// Form data coercion for HTML inputs
const FormSchema = v.object({
  name: v.pipe(v.string(), v.trim(), v.minLength(1)),
  age: v.pipe(
    v.string(),
    v.transform((val) => parseInt(val, 10)),
    v.number(),
    v.integer(),
    v.minValue(18)
  ),
  subscribe: v.pipe(
    v.optional(v.string()),
    v.transform((val) => val === "on")
  ),
});

Error Handling and Formatting

const result = v.safeParse(UserSchema, badInput);

if (!result.success) {
  // Access structured issues
  for (const issue of result.issues) {
    console.log({
      path: issue.path?.map((p) => p.key).join("."),
      message: issue.message,
      input: issue.input,
    });
  }

  // Flatten into field-error map (like Zod's flatten)
  const flat = v.flatten<typeof UserSchema>(result.issues);
  // { nested: { name: ["..."], email: ["..."] } }
}

Integration with React Hook Form

import { useForm } from "react-hook-form";
import { valibotResolver } from "@hookform/resolvers/valibot";
import * as v from "valibot";

const ContactSchema = v.object({
  name: v.pipe(v.string(), v.minLength(1, "Required")),
  email: v.pipe(v.string(), v.email("Invalid email")),
  message: v.pipe(
    v.string(),
    v.minLength(10, "Too short"),
    v.maxLength(500, "Too long")
  ),
});

type ContactData = v.InferOutput<typeof ContactSchema>;

export function ContactForm() {
  const {
    register,
    handleSubmit,
    formState: { errors },
  } = useForm<ContactData>({
    resolver: valibotResolver(ContactSchema),
  });

  return (
    <form onSubmit={handleSubmit((data) => console.log(data))}>
      <input {...register("name")} />
      {errors.name && <p>{errors.name.message}</p>}

      <input {...register("email")} />
      {errors.email && <p>{errors.email.message}</p>}

      <textarea {...register("message")} />
      {errors.message && <p>{errors.message.message}</p>}

      <button type="submit">Send</button>
    </form>
  );
}

Integration with Conform (Server Actions)

import { parseWithValibot } from "conform-to-valibot";
import * as v from "valibot";

const schema = v.object({
  title: v.pipe(v.string(), v.minLength(1, "Title required")),
  body: v.pipe(v.string(), v.minLength(10, "Too short")),
});

export async function createPost(prevState: unknown, formData: FormData) {
  const submission = parseWithValibot(formData, { schema });

  if (submission.status !== "success") {
    return submission.reply();
  }

  await db.posts.create({ data: submission.value });
  return submission.reply({ resetForm: true });
}

Comparison with Zod

// --- Zod ---
import { z } from "zod";
const ZodUser = z.object({
  name: z.string().min(1),
  email: z.string().email(),
  age: z.number().int().min(0),
});
type ZodUserType = z.infer<typeof ZodUser>;

// --- Valibot ---
import * as v from "valibot";
const ValibotUser = v.object({
  name: v.pipe(v.string(), v.minLength(1)),
  email: v.pipe(v.string(), v.email()),
  age: v.pipe(v.number(), v.integer(), v.minValue(0)),
});
type ValibotUserType = v.InferOutput<typeof ValibotUser>;

// Key differences:
// - Valibot: function-based, tree-shakable, ~1-3 KB bundle impact
// - Zod: method-chaining, class-based, ~12-14 KB bundle impact
// - Both: full TypeScript inference, transforms, refinements, async
// - Zod: larger ecosystem, more resolvers/integrations available
// - Valibot: better for edge runtimes and performance-critical paths

Best Practices

  • Import as namespace (import * as v from "valibot") — this keeps usage clean and bundlers still tree-shake unused functions. Alternatively, import individual functions for explicit control.
  • Use pipe consistently — even for single validations like v.pipe(v.string(), v.email()). This makes it easy to add more steps later without restructuring.
  • Prefer safeParse on the server — just like with Zod, avoid try/catch for validation errors. The discriminated result is safer and more ergonomic.
  • Use v.flatten to convert issues into a field-error map suitable for form display.
  • Use v.InferOutput (not v.InferInput) for the type of validated data — InferOutput reflects transforms, while InferInput reflects the shape before transforms.
  • Share schemas between client and server in a shared module for consistent validation on both sides.
  • Choose Valibot over Zod when bundle size is a primary concern — edge functions, mobile web, or micro-frontends where every kilobyte counts.

Anti-Patterns

  • Importing everything then using only string and object — while Valibot is tree-shakable, importing via named imports and then not using the namespace pattern can confuse some bundler configurations. Stick to import * as v or explicit named imports.
  • Nesting pipe inside pipe — keep pipes flat. Instead of v.pipe(v.pipe(v.string(), v.trim()), v.email()), write v.pipe(v.string(), v.trim(), v.email()).
  • Using Valibot for its own sake when Zod works — if bundle size is not a constraint and your team already uses Zod, switching purely for ideology adds churn without benefit.
  • Forgetting v.forward for cross-field errors — without forward, the error attaches to the root object instead of the specific field, making it invisible in form UIs.
  • Mixing parse and safeParse inconsistently — pick one pattern per layer (e.g., safeParse in API handlers, parse in trusted internal code) and stick with it.
  • Skipping the message parameter on validators — default error messages are technical ("Invalid type"). Always provide user-facing messages for any schema used in forms.

Install this skill directly: skilldb add forms-validation-skills

Get CLI access →