A complete guide to converting Zod schemas to Valibot. Understand the paradigm shift from
method chaining to pipe()-based composition, reduce your bundle size, and
migrate z.infer to v.InferOutput.
Convert your Zod schemas to Valibot automatically:
npx schemashift-cli migrate ./src --from zod --to valibot
Zod to Valibot migration requires a Pro or Team license. View pricing.
// schemas/product.ts
import { z } from 'zod';
const ProductSchema = z.object({
id: z.string().uuid(),
name: z.string().min(1).max(200),
price: z.number().positive(),
email: z.string().email(),
status: z.enum(['active', 'draft', 'archived']),
tags: z.array(z.string()).optional(),
metadata: z.record(z.string(), z.unknown()),
});
type Product = z.infer<typeof ProductSchema>;
const CategorySchema = z.discriminatedUnion('type', [
z.object({ type: z.literal('physical'), weight: z.number() }),
z.object({ type: z.literal('digital'), url: z.string().url() }),
]);
const ValidProduct = ProductSchema.refine(
(p) => p.price > 0,
{ message: 'Price must be positive' }
);
// schemas/product.ts
import * as v from 'valibot';
const ProductSchema = v.object({
id: v.pipe(v.string(), v.uuid()),
name: v.pipe(v.string(), v.minLength(1), v.maxLength(200)),
price: v.pipe(v.number(), v.minValue(0, 'Must be positive')),
email: v.pipe(v.string(), v.email()),
status: v.picklist(['active', 'draft', 'archived']),
tags: v.optional(v.array(v.string())),
metadata: v.record(v.string(), v.unknown()),
});
type Product = v.InferOutput<typeof ProductSchema>;
const CategorySchema = v.variant('type', [
v.object({ type: v.literal('physical'), weight: v.number() }),
v.object({ type: v.literal('digital'), url: v.pipe(v.string(), v.url()) }),
]);
const ValidProduct = v.pipe(
ProductSchema,
v.check((p) => p.price > 0, 'Price must be positive')
);
| Zod | Valibot | Notes |
|---|---|---|
z.string() |
v.string() |
|
z.number() |
v.number() |
|
z.boolean() |
v.boolean() |
|
z.object({ ... }) |
v.object({ ... }) |
|
z.array(X) |
v.array(X) |
|
z.string().email() |
v.pipe(v.string(), v.email()) |
Validations wrap in pipe() |
z.string().min(N) |
v.pipe(v.string(), v.minLength(N)) |
|
z.string().max(N) |
v.pipe(v.string(), v.maxLength(N)) |
|
z.number().min(N) |
v.pipe(v.number(), v.minValue(N)) |
|
z.number().int() |
v.pipe(v.number(), v.integer()) |
|
.optional() |
v.optional(schema) |
Wrapping function, not method |
.nullable() |
v.nullable(schema) |
Wrapping function, not method |
z.enum(['a', 'b']) |
v.picklist(['a', 'b']) |
|
z.union([A, B]) |
v.union([A, B]) |
|
z.discriminatedUnion('k', [...]) |
v.variant('k', [...]) |
|
z.literal('x') |
v.literal('x') |
|
z.record(K, V) |
v.record(K, V) |
|
z.tuple([A, B]) |
v.tuple([A, B]) |
|
.refine(fn, msg) |
v.check(fn, msg) |
Used inside v.pipe() |
.transform(fn) |
v.transform(fn) |
Used inside v.pipe() |
z.infer<typeof X> |
v.InferOutput<typeof X> |
Type inference helper |
v.pipe(base, validation1, validation2) instead of
method chaining. Every Zod chain like z.string().email().min(1) becomes
v.pipe(v.string(), v.email(), v.minLength(1)). This is the most common source of migration errors.
.superRefine() with ctx.addIssue() has no direct Valibot equivalent.
Simple cases can use v.check(), but complex multi-issue refinements need manual rewriting
with Valibot's v.forward() and custom actions. SchemaShift adds TODO comments for these.
z.object({}).catchall(z.string()) allows extra keys matching a schema. Valibot
does not support this pattern. Use v.record() alongside the object schema or restructure
your data model.
.describe('...') for documentation metadata does not exist in Valibot.
Schema descriptions are dropped during migration. If you use descriptions for OpenAPI generation,
you will need an alternative approach.
z.string().optional(). In Valibot, they are wrapper functions:
v.optional(v.string()). This inversion affects how you compose schemas and is a common
mistake during manual migration.
# Preview changes before migrating
npx schemashift-cli migrate ./src --from zod --to valibot --dry-run
# Run the migration
npx schemashift-cli migrate ./src --from zod --to valibot
# Estimate bundle size impact
npx schemashift-cli analyze ./src --bundle zod:valibot
# Migrate with cross-file resolution
npx schemashift-cli migrate ./src --from zod --to valibot --cross-file
import { z } from 'zod' with import * as v from 'valibot'z.object({}) calls to v.object({})v.pipe().optional() to v.optional(schema).nullable() to v.nullable(schema)z.enum([]) with v.picklist([])z.discriminatedUnion() with v.variant().refine() to v.check() inside v.pipe().transform() to v.transform() inside v.pipe()z.infer<typeof X> with v.InferOutput<typeof X>.describe() calls (no Valibot equivalent).superRefine() patterns manually@hookform/resolvers from zodResolver to valibotResolverpackage.json: add valibot, remove zodValibot offers significantly smaller bundle sizes through its tree-shakeable functional design. While Zod is around 13kB minified+gzipped, Valibot schemas only include the functions you actually use, often resulting in under 1kB for typical schemas. This makes it ideal for frontend applications where bundle size matters.
Zod uses method chaining: z.string().email().min(1). Valibot uses a pipe()
function that wraps the base type and validations: v.pipe(v.string(), v.email(), v.minLength(1)).
This functional approach enables better tree-shaking since unused validation functions are not bundled.
Yes, Valibot has v.variant() which serves the same purpose as z.discriminatedUnion().
The syntax differs: z.discriminatedUnion('type', [...]) becomes
v.variant('type', [...]). SchemaShift handles this conversion automatically.
SchemaShift automates Zod to Valibot conversion with pipe() wrapping, import rewriting, and resolver migration.
Get SchemaShift