Valibot
Valibot — modular schema validation, tiny bundle size, parse/safeParse, pipe transformations, type inference, and comparison with Zod
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 linesValibot
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
pipeconsistently — even for single validations likev.pipe(v.string(), v.email()). This makes it easy to add more steps later without restructuring. - Prefer
safeParseon the server — just like with Zod, avoid try/catch for validation errors. The discriminated result is safer and more ergonomic. - Use
v.flattento convert issues into a field-error map suitable for form display. - Use
v.InferOutput(notv.InferInput) for the type of validated data —InferOutputreflects transforms, whileInferInputreflects 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
stringandobject— while Valibot is tree-shakable, importing via named imports and then not using the namespace pattern can confuse some bundler configurations. Stick toimport * as vor explicit named imports. - Nesting
pipeinsidepipe— keep pipes flat. Instead ofv.pipe(v.pipe(v.string(), v.trim()), v.email()), writev.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.forwardfor cross-field errors — withoutforward, the error attaches to the root object instead of the specific field, making it invisible in form UIs. - Mixing
parseandsafeParseinconsistently — pick one pattern per layer (e.g.,safeParsein API handlers,parsein trusted internal code) and stick with it. - Skipping the
messageparameter 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
Related Skills
Conform
Conform — progressive enhancement forms, Server Actions, Zod integration, nested objects, arrays, intent buttons, and accessibility patterns
Formik
Formik — useFormik, Field, Form, validation with Yup, FieldArray, custom inputs, submission handling, and error display patterns
React Final Form
React Final Form — subscription-based form state management with fine-grained re-renders, field-level validation, decorator support, and zero dependencies
React Hook Form
React Hook Form — useForm, register, Controller, validation, nested fields, arrays (useFieldArray), DevTools, and performance optimization patterns
Vest
Vest — unit-test-inspired form validation framework with suite/test syntax, async rules, group support, warning-level validations, and framework-agnostic design
Yup
Yup — schema-based object validation with casting, transforms, conditional rules, localized error messages, and deep Formik integration