Valibot JSON Validation: Modular Schemas, Pipelines, TypeScript Inference & Bundle Size
Last updated:
Valibot validates JSON objects at runtime with up to 95% smaller bundles than Zod — a simple form schema ships under 1 kB minzipped versus Zod's 12.9 kB — because every validator is a separate named export that tree-shakers eliminate if unused. v.object() defines typed JSON objects, v.pipe() chains validators sequentially, v.parse() throws ValiError on failure, and v.safeParse() returns { success, output, issues } without throwing. TypeScript types are inferred with v.InferOutput<typeof schema> at zero runtime cost. This guide covers defining Valibot schemas for JSON objects and arrays, pipeline chains for multi-rule validation, converting Valibot schemas to JSON Schema with @valibot/to-json-schema, custom validation with v.check(), error handling, and integrating Valibot with React Hook Form.
Defining JSON Schemas with v.object and v.array
Valibot schema primitives map directly to JSON types: v.string(), v.number(), v.boolean(), v.null_() (note the underscore — null is a reserved word), and v.unknown() cover JSON scalar values. v.object({'{}'}) validates JSON objects with named fields; v.array(schema) validates JSON arrays of a uniform element type. Nest schemas to mirror any JSON structure. Mark fields optional with v.optional() or nullable with v.nullable(). Use v.record(v.string(), valueSchema) for dynamic JSON objects with unknown keys.
import * as v from 'valibot'
// ── Primitive schemas ──────────────────────────────────────────
const StringSchema = v.string()
const NumberSchema = v.number()
const BooleanSchema = v.boolean()
// ── v.object — validates a JSON object with named fields ───────
const UserSchema = v.object({
id: v.pipe(v.string(), v.uuid()), // must be valid UUID
name: v.pipe(v.string(), v.minLength(1), v.maxLength(100)),
email: v.pipe(v.string(), v.email()), // valid email format
age: v.optional(v.pipe(v.number(), v.integer(), v.minValue(0), v.maxValue(150))),
bio: v.nullable(v.string()), // string or null
createdAt: v.pipe(v.string(), v.isoTimestamp()), // ISO 8601 datetime
})
// Inferred TypeScript type — no separate interface needed
type User = v.InferOutput<typeof UserSchema>
// {
// id: string
// name: string
// email: string
// age?: number | undefined
// bio: string | null
// createdAt: string
// }
// ── v.array — validates a JSON array ──────────────────────────
const UsersSchema = v.array(UserSchema)
// also: v.object({ users: v.array(UserSchema) })
// ── Nested objects ─────────────────────────────────────────────
const OrderSchema = v.object({
id: v.string(),
total: v.pipe(v.number(), v.minValue(0)),
items: v.array(v.object({
productId: v.string(),
quantity: v.pipe(v.number(), v.integer(), v.minValue(1)),
price: v.pipe(v.number(), v.minValue(0)),
})),
user: UserSchema, // nested schema reuse
})
// ── v.record — dynamic JSON keys ──────────────────────────────
// Validates { [key: string]: number } — e.g. { clicks: 5, views: 100 }
const StatsSchema = v.record(v.string(), v.number())
// ── Optional fields with defaults ─────────────────────────────
// v.optional(schema, default) returns the default when field is absent
const ConfigSchema = v.object({
timeout: v.optional(v.number(), 5000),
retries: v.optional(v.number(), 3),
debug: v.optional(v.boolean(), false),
})
// ── Parsing ───────────────────────────────────────────────────
const json = {
id: '550e8400-e29b-41d4-a716-446655440000',
name: 'Alice',
email: 'alice@example.com',
bio: null,
createdAt: '2026-05-28T10:00:00Z',
}
const user: User = v.parse(UserSchema, json) // throws ValiError if invalid
const result = v.safeParse(UserSchema, json) // never throws
if (result.success) {
console.log(result.output.email) // typed as string
} else {
console.error(result.issues) // BaseIssue[]
}Notice that Valibot's built-in validators like v.minLength(), v.email(), and v.uuid() are standalone functions rather than methods — you place them inside v.pipe() to chain them onto a base schema. This is the core architectural difference from Zod: validators are not attached to a prototype chain but are separate tree-shakeable exports. If you never use v.uuid() in your project, your bundler strips it entirely, keeping the runtime payload minimal.
Pipelines: Chaining Multiple Validators with v.pipe
v.pipe(baseSchema, ...actions) is Valibot's primary composition tool. The first argument must be a base schema (v.string(), v.number(), v.object(), etc.). Subsequent arguments are validation actions or transformation actions. Each step receives the output of the previous step. Validation actions (like v.email(), v.minLength()) check the value and pass it through unchanged on success. Transformation actions (like v.transform(), v.toLowerCase()) convert the value to a new output type.
import * as v from 'valibot'
// ── Basic pipeline — validate a string as an email ─────────────
const EmailSchema = v.pipe(
v.string(),
v.trim(), // transform: strip surrounding whitespace
v.toLowerCase(), // transform: normalize to lowercase
v.email('Invalid email'), // validate: check email format
v.maxLength(254, 'Email too long'),
)
// ── Number pipeline — integer between 1 and 100 ───────────────
const PercentSchema = v.pipe(
v.number(),
v.integer('Must be a whole number'),
v.minValue(1, 'Must be at least 1'),
v.maxValue(100, 'Must be at most 100'),
)
// ── String → Date transformation pipeline ─────────────────────
// Input type: string — Output type: Date
const DateSchema = v.pipe(
v.string(),
v.isoTimestamp(),
v.transform(s => new Date(s)),
)
type DateOutput = v.InferOutput<typeof DateSchema> // Date
// ── Reusable sub-pipes ─────────────────────────────────────────
// Store intermediate pipelines as values and spread into larger ones
const nonEmptyString = [v.minLength(1, 'Required')] as const
const shortString = [v.maxLength(100, 'Too long')] as const
const NameSchema = v.pipe(v.string(), ...nonEmptyString, ...shortString)
const TitleSchema = v.pipe(v.string(), ...nonEmptyString, v.maxLength(200, 'Title too long'))
// ── Pipeline on object schema ──────────────────────────────────
// Apply cross-field validation AFTER object fields are validated
const RangeSchema = v.pipe(
v.object({
start: v.number(),
end: v.number(),
}),
v.check(
data => data.end > data.start,
'End must be greater than start',
),
)
// ── URL validation with transformation ────────────────────────
const UrlSchema = v.pipe(
v.string(),
v.url('Must be a valid URL'),
v.transform(s => new URL(s)), // string → URL object
)
type UrlOutput = v.InferOutput<typeof UrlSchema> // URL
// ── Array pipeline — array-level constraints ───────────────────
const TagsSchema = v.pipe(
v.array(v.pipe(v.string(), v.minLength(1))),
v.minLength(1, 'At least one tag required'),
v.maxLength(5, 'Maximum 5 tags'),
)
// ── Parsing with pipeline ─────────────────────────────────────
const result = v.safeParse(EmailSchema, ' ALICE@EXAMPLE.COM ')
if (result.success) {
console.log(result.output) // 'alice@example.com' — trimmed & lowercased
}Pipelines run left-to-right and short-circuit on the first failure by default. This means if v.string() fails (e.g., the input is a number), v.email() never runs — there's no point validating email format on a non-string. Transformation actions like v.trim() and v.toLowerCase() run only when all preceding validations pass, preventing errors inside transform functions caused by invalid input. This ordering guarantee makes pipelines safe for complex multi-step validation chains.
parse vs safeParse: Throwing vs Non-Throwing Validation
Valibot provides two parsing modes: v.parse(schema, input) throws a ValiError on failure; v.safeParse(schema, input) returns a discriminated union result and never throws. The choice follows the same principle as Zod: use v.parse() for internal invariants where failure is a bug, and v.safeParse() at every external boundary where you need to handle errors gracefully.
import * as v from 'valibot'
// ── v.parse() — throws ValiError on failure ────────────────────
// Good for: startup validation, internal invariants
const EnvSchema = v.object({
DATABASE_URL: v.pipe(v.string(), v.url()),
PORT: v.pipe(v.string(), v.transform(Number), v.integer()),
NODE_ENV: v.picklist(['development', 'production', 'test']),
})
// Throws immediately on misconfigured env — fail fast at startup
const env = v.parse(EnvSchema, process.env)
// env.DATABASE_URL is typed as string — no need for ! assertions
// ── v.safeParse() — never throws ──────────────────────────────
// Good for: API handlers, form validation, external data
import type { NextRequest } from 'next/server'
import { NextResponse } from 'next/server'
const CreateUserSchema = v.object({
name: v.pipe(v.string(), v.minLength(1), v.maxLength(100)),
email: v.pipe(v.string(), v.email()),
age: v.pipe(v.number(), v.integer(), v.minValue(18)),
})
export async function POST(req: NextRequest) {
const body = await req.json()
const result = v.safeParse(CreateUserSchema, body)
if (!result.success) {
// Build flat field → first-error map from issues
const fieldErrors = result.issues.reduce<Record<string, string>>(
(acc, issue) => {
const key = issue.path?.map(p => (p as { key: string }).key).join('.') ?? '_root'
if (!acc[key]) acc[key] = issue.message
return acc
},
{}
)
return NextResponse.json(
{ success: false, errors: fieldErrors },
{ status: 422 }
)
}
// result.output is fully typed as { name: string; email: string; age: number }
const user = await db.createUser(result.output)
return NextResponse.json({ success: true, user }, { status: 201 })
}
// ── safeParse return type shape ────────────────────────────────
// result.success === true → { success: true, output: T, typed: true }
// result.success === false → { success: false, issues: BaseIssue[], typed: false }
//
// Each issue has:
// kind: 'schema' | 'validation' | 'transformation'
// type: string — the schema or action name (e.g. 'string', 'email')
// input: unknown — the value that failed
// expected: string — what was expected (e.g. 'string', '>=18')
// received: string — what was received (e.g. '42', 'number')
// message: string — human-readable description
// path: PathItem[] | undefined — path to the failing field
// ── Async variants ─────────────────────────────────────────────
// For schemas with v.checkAsync() or v.transformAsync() actions
const output = await v.parseAsync(schema, input)
const asyncResult = await v.safeParseAsync(schema, input)The ValiError thrown by v.parse() has the same issues array as the safeParse failure result — you can catch it and inspect error.issues programmatically. The issue.path array contains PathItem objects with a key property for object fields and an index property for array elements. Walk the full path to identify deeply nested failures. For async validation (database uniqueness checks, remote API calls), use v.parseAsync() and v.safeParseAsync() — synchronous parse methods will throw if the schema contains async actions.
TypeScript Type Inference with InferOutput and InferInput
v.InferOutput<typeof schema> extracts the output TypeScript type from a Valibot schema — the type after all transformations have run. v.InferInput<typeof schema> gives the input type — what you must pass to v.parse(). These are purely compile-time operations with zero runtime cost. When the schema has no transforms, InferInput and InferOutput are identical.
import * as v from 'valibot'
// ── Basic inference ────────────────────────────────────────────
const ProductSchema = v.object({
id: v.pipe(v.string(), v.uuid()),
name: v.string(),
price: v.pipe(v.number(), v.minValue(0)),
tags: v.array(v.string()),
metadata: v.optional(v.record(v.string(), v.unknown())),
})
type Product = v.InferOutput<typeof ProductSchema>
// {
// id: string
// name: string
// price: number
// tags: string[]
// metadata?: Record<string, unknown>
// }
// ── Input vs Output types (when transforms are involved) ───────
const DateStringSchema = v.pipe(
v.string(),
v.isoTimestamp(),
v.transform(s => new Date(s)),
)
type DateInput = v.InferInput<typeof DateStringSchema> // string
type DateOutput = v.InferOutput<typeof DateStringSchema> // Date
// InferOutput === what v.parse() returns (post-transform)
// InferInput === what v.parse() accepts (pre-transform)
// ── Using inferred types in function signatures ─────────────────
// Share schema between frontend (validation) and backend (handler)
// In a shared /types/api.ts file:
export const ApiUserSchema = v.object({
id: v.string(),
name: v.string(),
email: v.pipe(v.string(), v.email()),
})
export type ApiUser = v.InferOutput<typeof ApiUserSchema>
// Frontend — validates response
async function fetchUser(id: string): Promise<ApiUser> {
const res = await fetch(`/api/users/${id}`)
const json = await res.json()
return v.parse(ApiUserSchema, json) // throws if API changed shape
}
// Backend — uses same type in handler
async function createUser(data: ApiUser): Promise<void> {
await db.insert('users', data) // data fully typed as ApiUser
}
// ── Extracting sub-schema types ────────────────────────────────
// Access nested schema entries via .entries (object) or .item (array)
const ItemSchema = ProductSchema.entries.price // v.SchemaWithPipe<...>
type Price = v.InferOutput<typeof ItemSchema> // number
// ── Discriminated unions with type inference ───────────────────
const EventSchema = v.variant('type', [
v.object({ type: v.literal('click'), x: v.number(), y: v.number() }),
v.object({ type: v.literal('keydown'), key: v.string() }),
v.object({ type: v.literal('resize'), width: v.number(), height: v.number() }),
])
type Event = v.InferOutput<typeof EventSchema>
// { type: 'click'; x: number; y: number }
// | { type: 'keydown'; key: string }
// | { type: 'resize'; width: number; height: number }A practical pattern for full-stack TypeScript: define all API schemas in a shared package, export both the schema constant and the inferred type, and import them in both frontend (for validation) and backend (for handler types). The schema is the single source of truth — TypeScript types and JSON Schema documents are derived artifacts. Valibot's zero-dependency, modular design means the shared schema package adds negligible weight to both server and browser bundles.
Converting Valibot Schemas to JSON Schema
The official @valibot/to-json-schema package converts Valibot schemas to standard JSON Schema draft 7 objects. This lets you use Valibot as your runtime validator while generating JSON Schema for OpenAPI documentation, Ajv-based validation in non-TypeScript consumers, and contract testing tools that expect JSON Schema format.
// npm install valibot @valibot/to-json-schema
import * as v from 'valibot'
import { toJsonSchema } from '@valibot/to-json-schema'
// ── Basic conversion ───────────────────────────────────────────
const UserSchema = v.object({
id: v.pipe(v.string(), v.uuid()),
name: v.pipe(v.string(), v.minLength(1), v.maxLength(100)),
email: v.pipe(v.string(), v.email()),
age: v.optional(v.pipe(v.number(), v.integer(), v.minValue(0))),
role: v.picklist(['admin', 'user', 'guest']),
})
const jsonSchema = toJsonSchema(UserSchema)
// {
// type: 'object',
// properties: {
// id: { type: 'string', format: 'uuid' },
// name: { type: 'string', minLength: 1, maxLength: 100 },
// email: { type: 'string', format: 'email' },
// age: { type: 'integer', minimum: 0 },
// role: { enum: ['admin', 'user', 'guest'] },
// },
// required: ['id', 'name', 'email', 'role'],
// }
console.log(JSON.stringify(jsonSchema, null, 2))
// ── Using JSON Schema in an OpenAPI spec ───────────────────────
const openApiSpec = {
openapi: '3.1.0',
info: { title: 'My API', version: '1.0.0' },
components: {
schemas: {
User: toJsonSchema(UserSchema),
},
},
paths: {
'/users': {
post: {
requestBody: {
content: {
'application/json': {
schema: { '$ref': '#/components/schemas/User' },
},
},
},
},
},
},
}
// ── Ajv validation using generated JSON Schema ─────────────────
import Ajv from 'ajv'
import addFormats from 'ajv-formats'
const ajv = new Ajv()
addFormats(ajv)
const validate = ajv.compile(toJsonSchema(UserSchema))
const valid = validate({ id: '...', name: 'Alice', email: 'alice@example.com', role: 'user' })
if (!valid) console.error(validate.errors)
// ── Array schema conversion ────────────────────────────────────
const UsersArraySchema = v.array(UserSchema)
const arrayJsonSchema = toJsonSchema(UsersArraySchema)
// { type: 'array', items: { type: 'object', properties: {...}, required: [...] } }
// ── Generating JSON Schema at build time ───────────────────────
// scripts/generate-schemas.ts
import { writeFileSync } from 'fs'
writeFileSync(
'public/schemas/user.schema.json',
JSON.stringify(toJsonSchema(UserSchema), null, 2),
)Schemas containing v.transform() actions are converted based on the input type (before the transform), since JSON Schema describes data shapes rather than runtime behavior. Schemas with v.unknown() or v.any() produce {} (accepts everything) in JSON Schema. If your schema uses Valibot-specific actions that have no JSON Schema equivalent (e.g., v.check() with custom logic), toJsonSchema omits those constraints from the output — the JSON Schema will be less strict than the Valibot schema, so runtime Valibot validation remains the authoritative check.
Custom Validation with v.check and v.transform
v.check(fn, message) adds custom synchronous validation logic inside a pipeline. The function receives the typed, validated value and returns a boolean. v.transform(fn) converts the validated value to a new type. Both run inside v.pipe() after preceding validators pass. For async validation (database lookups, HTTP calls), use v.checkAsync() and v.safeParseAsync().
import * as v from 'valibot'
// ── v.check() — custom boolean validation ─────────────────────
const PasswordSchema = v.pipe(
v.string(),
v.minLength(8, 'Password must be at least 8 characters'),
v.check(s => /[A-Z]/.test(s), 'Must contain at least one uppercase letter'),
v.check(s => /[0-9]/.test(s), 'Must contain at least one number'),
v.check(s => /[^A-Za-z0-9]/.test(s), 'Must contain at least one special character'),
)
// ── Cross-field check on object schema ────────────────────────
const SignupSchema = v.pipe(
v.object({
email: v.pipe(v.string(), v.email()),
password: v.pipe(v.string(), v.minLength(8)),
confirmPassword: v.string(),
}),
v.check(
data => data.password === data.confirmPassword,
'Passwords do not match',
),
v.check(
data => !data.email.includes(data.password),
'Password must not contain your email address',
),
)
// ── v.transform() — reshape data ──────────────────────────────
// Convert snake_case API response to camelCase domain object
const ApiUserSchema = v.pipe(
v.object({
user_id: v.string(),
first_name: v.string(),
last_name: v.string(),
created_at: v.pipe(v.string(), v.isoTimestamp()),
}),
v.transform(data => ({
userId: data.user_id,
firstName: data.first_name,
lastName: data.last_name,
createdAt: new Date(data.created_at), // string → Date
})),
)
type DomainUser = v.InferOutput<typeof ApiUserSchema>
// { userId: string; firstName: string; lastName: string; createdAt: Date }
// ── String trimming and normalization pipeline ─────────────────
const SlugSchema = v.pipe(
v.string(),
v.trim(),
v.toLowerCase(),
v.transform(s => s.replace(/s+/g, '-').replace(/[^a-z0-9-]/g, '')),
v.minLength(1, 'Slug cannot be empty after normalization'),
v.maxLength(100, 'Slug too long'),
)
// ── v.checkAsync() — async custom validation ───────────────────
// For database uniqueness checks, external API calls
const UniqueEmailSchema = v.pipeAsync(
v.string(),
v.email(),
v.checkAsync(
async (email) => {
const existing = await db.users.findOne({ email })
return !existing
},
'Email address is already registered',
),
)
// Use safeParseAsync for schemas with async actions
const result = await v.safeParseAsync(UniqueEmailSchema, 'alice@example.com')
// ── Conditional transformation ─────────────────────────────────
// Transform a value only when it meets a condition
const FlexibleIdSchema = v.pipe(
v.union([v.string(), v.number()]),
v.transform(id => String(id)), // normalise to string
v.pipe(v.string(), v.minLength(1)),
)
type FlexibleId = v.InferOutput<typeof FlexibleIdSchema> // stringv.check() runs after all preceding pipeline steps pass — so the callback always receives a correctly-typed value. You can add multiple v.check() calls in a single pipeline; each runs sequentially and the first failure short-circuits the rest. For cross-field validation, put v.check() after the v.object() base schema in a v.pipe() so all individual fields are validated first before the cross-field check runs. This mirrors Zod's .superRefine() for cross-field rules.
Integrating Valibot with React Hook Form
React Hook Form integrates with Valibot via the valibotResolver from @hookform/resolvers. Define your form schema with Valibot, pass it to useForm, and get fully typed errors and form values — all with Valibot's <1 kB per-schema bundle cost instead of Zod's 12.9 kB.
// npm install valibot @hookform/resolvers react-hook-form
import * as v from 'valibot'
import { useForm } from 'react-hook-form'
import { valibotResolver } from '@hookform/resolvers/valibot'
// ── Define Valibot schema ──────────────────────────────────────
const SignupSchema = v.object({
name: v.pipe(v.string(), v.minLength(1, 'Name is required'), v.maxLength(100)),
email: v.pipe(v.string(), v.email('Invalid email address')),
password: v.pipe(
v.string(),
v.minLength(8, 'Password must be at least 8 characters'),
v.check(s => /[A-Z]/.test(s), 'Must contain one uppercase letter'),
),
age: v.pipe(
v.number('Age must be a number'),
v.integer(),
v.minValue(18, 'Must be 18 or older'),
),
})
type SignupForm = v.InferOutput<typeof SignupSchema>
// ── Use valibotResolver in useForm ─────────────────────────────
function SignupFormComponent() {
const {
register,
handleSubmit,
formState: { errors, isSubmitting },
} = useForm<SignupForm>({
resolver: valibotResolver(SignupSchema),
defaultValues: { name: '', email: '', password: '', age: 18 },
})
const onSubmit = async (data: SignupForm) => {
// data is fully typed as SignupForm
// age is number (not string) — Valibot validates the type
await fetch('/api/signup', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data),
})
}
return (
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4">
<div>
<label htmlFor="name">Name</label>
<input id="name" {...register('name')} />
{errors.name && <span className="text-red-600">{errors.name.message}</span>}
</div>
<div>
<label htmlFor="email">Email</label>
<input id="email" type="email" {...register('email')} />
{errors.email && <span className="text-red-600">{errors.email.message}</span>}
</div>
<div>
<label htmlFor="password">Password</label>
<input id="password" type="password" {...register('password')} />
{errors.password && <span className="text-red-600">{errors.password.message}</span>}
</div>
<div>
<label htmlFor="age">Age</label>
<input id="age" type="number" {...register('age', { valueAsNumber: true })} />
{errors.age && <span className="text-red-600">{errors.age.message}</span>}
</div>
<button type="submit" disabled={isSubmitting}>
{isSubmitting ? 'Signing up...' : 'Sign Up'}
</button>
</form>
)
}
// ── Server Action with Valibot validation ──────────────────────
'use server'
export async function signupAction(formData: FormData) {
const raw = {
name: formData.get('name'),
email: formData.get('email'),
password: formData.get('password'),
age: Number(formData.get('age')),
}
const result = v.safeParse(SignupSchema, raw)
if (!result.success) {
return { success: false, errors: result.issues.map(i => i.message) }
}
await createUser(result.output)
return { success: true }
}The valibotResolver calls v.safeParseAsync() internally (to support schemas with async actions), maps Valibot issue paths to React Hook Form's nested error structure, and returns the typed output on success. Use valueAsNumber: true in register() for numeric fields — HTML inputs always return strings, so this converts them before Valibot validates. Alternatively, use v.pipe(v.string(), v.transform(Number), v.integer()) in your schema for built-in string-to-number coercion matching Zod's z.coerce.number() behavior.
FAQ
How is Valibot different from Zod in bundle size and API design?
Valibot is up to 95% smaller than Zod on a per-schema basis. Zod ships as a monolithic 12.9 kB minzipped bundle — you get the whole library regardless of what you use. Valibot's modular design means tree-shakers eliminate every function you never import: a simple string schema with an email check totals under 1 kB minzipped. The API also differs fundamentally: Zod uses method chaining (z.string().email().max(100)); Valibot uses a functional pipeline (v.pipe(v.string(), v.email(), v.maxLength(100))). Pipelines are more composable — you can store intermediate pipes in variables, spread them into new pipes, and tree-shake unused validators with full precision. Valibot also has zero runtime dependencies. Valibot v1.0 released in 2024 and is now at stable v1.x with full TypeScript inference.
How do I define optional and nullable fields in Valibot?
Use v.optional(schema) to allow a field to be undefined or absent — the inferred type adds | undefined. Use v.nullable(schema) to allow null — the type adds | null. Use v.nullish(schema) to allow both null and undefined. Optional fields can have defaults: v.optional(v.number(), 5000) returns 5000 when the field is absent or undefined. Valibot v1.x also provides v.exactOptional() for strict TypeScript exactOptionalPropertyTypes compatibility. For JSON API responses where a field may be absent or null, use v.nullish(schema). Example: v.object({'{ bio: v.nullable(v.string()), nickname: v.optional(v.string()) }'}).
How does v.pipe() work for chaining validators?
v.pipe() chains multiple validators so the output of each step becomes the input of the next. The first argument is a base schema (v.string(), v.number(), etc.); subsequent arguments are validation or transformation actions. Example: v.pipe(v.string(), v.email(), v.maxLength(100)) checks string type, then email format, then maximum length of 100. Valibot runs steps left-to-right and short-circuits on the first failure. Transformation actions change the output type: v.pipe(v.string(), v.transform(s {'=> new Date(s)'}) produces a Date output from a string input. You can store reusable sub-pipelines as arrays and spread them into larger v.pipe() calls, enabling DRY composition without prototype-chain overhead.
How do I convert a Valibot schema to JSON Schema?
Install @valibot/to-json-schema and call toJsonSchema(schema) to get a standard JSON Schema draft 7 object. The conversion supports v.object(), v.array(), v.string(), v.number(), v.boolean(), v.null_(), v.union(), v.pipe() chains with common actions (v.email(), v.url(), v.minLength(), v.maxLength(), v.regex()), v.optional(), and v.nullable(). The returned plain object can be serialized with JSON.stringify() and used as an OpenAPI component schema, passed to Ajv, or committed as a static schema file for non-TypeScript consumers. Schemas with v.transform() are converted based on input type. Custom v.check() logic has no JSON Schema equivalent and is omitted from the output.
How do I handle validation errors in Valibot?
v.parse() throws a ValiError with an issues array on failure. v.safeParse() returns { success: false, issues: BaseIssue[] } without throwing — always use this at API boundaries. Each issue has kind, type, input, expected, received, message, and path. The path array contains objects with a key property for object fields. To build a flat field-to-error map: issues.reduce((acc, i) => {'{ const k = i.path?.map(p => p.key).join(\'.\') ?? \'_\'; if (!acc[k]) acc[k] = i.message; return acc; }, {})'}. Unlike Zod's ZodError.flatten(), Valibot does not ship a built-in flatten method — you construct the error shape you need directly from the issues array. This keeps Valibot's bundle minimal.
How do I validate nested JSON objects with Valibot?
Nest v.object() calls and define sub-schemas as named constants for reuse. For example: const addressSchema = v.object({'{ street: v.string(), city: v.string(), zip: v.pipe(v.string(), v.regex(/^\\d{5}$/)) }'}), then reference it in a parent: v.object({'{ id: v.string(), address: addressSchema }'}). Valibot validates recursively — a failure in address.zip produces an issue with path = [{'{ key: \'address\' }, { key: \'zip\' }'}]. For arrays of objects, use v.array(itemSchema). For dynamic keys, use v.record(v.string(), v.unknown()). Wrap v.object() with v.looseObject() to allow extra keys, or use v.strictObject() to reject them. Sub-schemas are immutable values that can be shared safely across many parent schemas.
How do I integrate Valibot with React Hook Form?
Install @hookform/resolvers and import valibotResolver. Define your schema with v.object() and v.pipe(), then pass it as the resolver: useForm<v.InferOutput<typeof schema>>({ resolver: valibotResolver(schema) }). The errors object is typed to match your schema. The valibotResolver calls v.safeParseAsync() internally, supporting async validation actions. Use valueAsNumber: true in register() for number inputs, or use v.pipe(v.string(), v.transform(Number)) in the schema for built-in coercion. Because a simple Valibot form schema ships under 1 kB, the total React Hook Form integration bundle is significantly smaller than the equivalent Zod + zodResolver setup at 12.9 kB.
How do I write custom validators in Valibot with v.check?
v.check(fn, message) adds a custom synchronous validation action inside v.pipe(). The function receives the typed, validated value and must return a boolean — false triggers a validation issue with the provided message. Example: v.pipe(v.string(), v.check(s => {'!s.includes(\'admin\')'}, 'Reserved word')). For cross-field rules, add v.check() after a v.object() in a pipeline. For async validation (database uniqueness), use v.checkAsync(async fn, message) inside v.pipeAsync() and call v.safeParseAsync(). Multiple v.check() calls in one pipeline run sequentially; the first failure short-circuits the rest. v.check() is the Valibot equivalent of Zod's .refine() — a composable, tree-shakeable function that adds no bundle weight unless imported.
Validate your JSON instantly
Paste any JSON into Jsonic's validator to check syntax and structure in real time — no schema required.
Open JSON ValidatorFurther reading and primary sources
- Valibot Documentation — Official Valibot docs: all schemas, actions, pipelines, and TypeScript inference patterns
- Valibot GitHub Repository — Source code, changelog, and community issues for Valibot
- @valibot/to-json-schema — Official package to convert Valibot schemas to JSON Schema draft 7
- @hookform/resolvers — valibotResolver — React Hook Form Valibot resolver documentation and usage examples
- Valibot vs Zod Bundle Size Comparison — Official comparison of Valibot and Zod API design, bundle size, and feature parity