Both Zod and Valibot solve the same problem: TypeScript types only exist at compile time, but you need to validate unknown data at runtime — API inputs, form data, environment variables. Both generate TypeScript types from schemas. Both work with tRPC, Hono, and React Hook Form. The differences are bundle size, API design, and ecosystem maturity.
The Bundle Gap
| Library | Minified + Gzipped | Tree-shaking |
|---|---|---|
| Zod v3 | ~55kb | Limited |
| Valibot v1 | ~10-12kb total, ~1-2kb per schema | Full |
For Node.js APIs, irrelevant. For Cloudflare Workers or browser bundles, it matters.
API: Method Chaining vs Function Pipeline
// Zod — chained methods
const CreateUserSchema = z.object({
name: z.string().min(1).max(100),
email: z.string().email(),
role: z.enum(['USER', 'ADMIN']).default('USER'),
})
type CreateUser = z.infer<typeof CreateUserSchema>
const result = CreateUserSchema.safeParse(req.body)
if (!result.success) {
const errors = result.error.flatten().fieldErrors
}
// Valibot — functional pipe()
import { object, string, pipe, minLength, maxLength, email, picklist, optional } from 'valibot'
const CreateUserSchema = object({
name: pipe(string(), minLength(1), maxLength(100)),
email: pipe(string(), email()),
role: optional(picklist(['USER', 'ADMIN']), 'USER'),
})
type CreateUser = InferOutput<typeof CreateUserSchema>
const result = safeParse(CreateUserSchema, req.body)
if (!result.success) {
const issues = result.issues // Array<{ message, path }>
}
Zod reads more naturally. Valibot is fully tree-shakeable — bundlers drop functions you don't import.
Transforms and Coercion
// Query param coercion — Zod
const PaginationSchema = z.object({
page: z.coerce.number().int().min(1).default(1),
limit: z.coerce.number().int().min(1).max(100).default(20)
})
// Valibot equivalent
const PaginationSchema = object({
page: optional(pipe(coerce(number(), Number), integer(), minValue(1)), 1),
limit: optional(pipe(coerce(number(), Number), integer(), minValue(1), maxValue(100)), 20)
})
i18n Error Messages
Valibot's setGlobalConfig gives you a single translation layer for the whole app:
import { setGlobalConfig } from 'valibot'
setGlobalConfig({
message: (issue) => {
if (issue.type === 'min_length') return `Mínimo ${issue.requirement} caracteres`
if (issue.type === 'email') return 'Email inválido'
return 'Campo inválido'
}
})
// Every schema picks this up automatically
Framework Integration
Both work with the same ecosystem:
// React Hook Form
import { zodResolver } from '@hookform/resolvers/zod'
import { valibotResolver } from '@hookform/resolvers/valibot'
// Same API, different resolver
// tRPC — Valibot supported since v11
.input(object({ name: string(), email: pipe(string(), email()) }))
// Hono
import { vValidator } from '@hono/valibot-validator'
app.post('/users', vValidator('json', schema), async (c) => { ... })
Decision Framework
| Situation | Choose |
|---|---|
| Existing codebase, Node.js API | Zod |
| Cloudflare Workers, edge functions | Valibot |
| Browser bundle, size-sensitive app | Valibot |
| Need full i18n error messages | Valibot |
| Ecosystem maturity matters | Zod |
They're interchangeable for most use cases. Migrating is a find-and-replace-plus-refactor, not a rearchitecture.
Full article at stacknotice.com/blog/zod-vs-valibot-2026
Top comments (0)