Fix: Valibot Not Working — v.pipe Syntax, Error Messages, Async Validation, and Zod Migration
Quick Answer
How to fix Valibot errors — v.pipe vs chained methods, parse vs safeParse, async pipelines with v.parseAsync, custom error messages with v.message, optional/nullable variants, Zod migration patterns, and form library integration.
The Error
You translate a Zod schema to Valibot and it doesn’t compile:
// Zod:
const schema = z.string().min(3).email();
// Naive Valibot translation — TypeError:
const schema = v.string().min(3).email();
// v.string() returns a schema, not a chain — .min isn't a method.Or parse throws on invalid data instead of returning an error:
const result = v.parse(v.number(), "not a number");
// throws ValiErrorOr async validators don’t run:
const schema = v.pipe(
v.string(),
v.checkAsync(async (val) => await isUnique(val), "must be unique"),
);
const result = v.parse(schema, "alice"); // throws "called sync parse on async schema"Or error messages are generic and don’t help users:
const result = v.safeParse(v.pipe(v.string(), v.email()), "not-email");
console.log(result.issues[0].message);
// "Invalid email" — fine, but you wanted "Please enter a valid email address"Why This Happens
Valibot has a fundamentally different design from Zod:
- Functions, not methods. Each validator (
v.string,v.email,v.minLength) is a standalone function. They compose withv.pipe(schema, ...actions). This is what makes Valibot tree-shakable to ~1-3 KB versus Zod’s ~13 KB. parsethrows;safeParsereturns a result. Match the call style to your error handling preference.parseis shorter when you have a try/catch up the stack;safeParseis cleaner for forms.- Async schemas need async parse. If any node in the schema uses
Asyncvariants (v.checkAsync,v.transformAsync), you must callv.parseAsync/v.safeParseAsync. Mixing sync and async crashes. - Validation pipelines vs schema composition.
v.pipe(v.string(), v.email())adds actions to a string schema.v.object({...})composes child schemas. Mixing them up causes type errors.
Fix 1: Use v.pipe for Validators
The Zod equivalent translates as:
import * as v from "valibot";
// Zod: z.string().min(3).max(100).email()
const schema = v.pipe(
v.string(),
v.minLength(3),
v.maxLength(100),
v.email(),
);
const result = v.parse(schema, "[email protected]");Each “method-like” chain in Zod becomes a sibling action in v.pipe(...). Order matters — actions run left-to-right.
For nullable and optional:
// Zod: z.string().optional()
const schema = v.optional(v.string());
// Zod: z.string().nullable()
const schema = v.nullable(v.string());
// Zod: z.string().nullish()
const schema = v.nullish(v.string());
// With default:
const schema = v.optional(v.string(), "default value");For arrays:
// Zod: z.array(z.string())
const schema = v.array(v.string());
// With min/max length:
const schema = v.pipe(v.array(v.string()), v.minLength(1), v.maxLength(10));Pro Tip: Import as import * as v from "valibot" — concise and consistent with the docs. Importing each function (import { string, pipe, email } from "valibot") is also fine and tree-shakes the same.
Fix 2: Pick parse or safeParse
// Throws ValiError on invalid:
try {
const user = v.parse(UserSchema, data);
console.log(user); // typed as User
} catch (err) {
if (err instanceof v.ValiError) {
console.log(err.issues);
}
}
// Returns { success, output, issues }:
const result = v.safeParse(UserSchema, data);
if (result.success) {
console.log(result.output); // typed as User
} else {
console.log(result.issues);
}For form-style validation where you want to display errors, safeParse avoids the try/catch boilerplate.
The issues array contains:
{
kind: "validation",
type: "email",
input: "not-email",
expected: undefined,
received: '"not-email"',
message: "Invalid email",
path: [{ key: "email", value: ..., type: "object", input: {...} }],
}path is how you locate the failing field in nested objects:
const issues = result.issues;
const byField = Object.fromEntries(
issues.map((i) => [i.path?.map((p) => p.key).join("."), i.message]),
);
// { "user.email": "Invalid email", "user.age": "Must be positive" }Fix 3: Async Validation
Async actions require Async variants and parseAsync:
const schema = v.pipeAsync(
v.string(),
v.checkAsync(
async (val) => {
const taken = await usernameTaken(val);
return !taken;
},
"Username is already taken",
),
);
const result = await v.parseAsync(schema, "alice");
// or
const result = await v.safeParseAsync(schema, "alice");Three pieces:
v.pipeAsync— the pipe must be async if any action is.v.checkAsync— async predicate. Returnstruefor valid,falsefor invalid.v.parseAsync/v.safeParseAsync— the runner.
For object schemas with one async field:
const schema = v.objectAsync({
username: v.pipeAsync(
v.string(),
v.checkAsync(async (val) => !(await usernameTaken(val)), "Taken"),
),
email: v.pipe(v.string(), v.email()), // sync — fine inside objectAsync
});
const result = await v.parseAsync(schema, data);Use v.objectAsync (not v.object) once any field is async. Sync subschemas inside it work fine — the wrapping object must be async.
Common Mistake: Calling v.parse on an async schema. Valibot throws “called sync parse on async schema.” Always check whether your schema tree contains any Async variant and pick the right runner.
Fix 4: Custom Error Messages
Pass a message as the second argument to most actions:
const schema = v.pipe(
v.string("Name must be a string"),
v.minLength(2, "Name must be at least 2 characters"),
v.maxLength(50, "Name cannot exceed 50 characters"),
);For dynamic messages:
const schema = v.pipe(
v.number(),
v.minValue(0, (input) => `Got ${input.received} — must be positive`),
);For global defaults (e.g. i18n):
v.setGlobalConfig({
lang: "ja", // sets default language for built-in messages
message: (issue) => `[${issue.type}] ${issue.message}`, // wrap all messages
});For per-field localization in a form, build a translation map:
function translate(issue: v.BaseIssue<unknown>): string {
const key = `${issue.type}.${issue.received}`;
return i18n[key] ?? issue.message;
}
const errors = result.issues?.map(translate) ?? [];Fix 5: Transforms
v.transform converts the parsed value:
const schema = v.pipe(
v.string(),
v.transform((val) => val.trim().toLowerCase()),
v.email(),
);
v.parse(schema, " [email protected] ");
// "[email protected]"Order matters — transforms before validations apply first:
// Trim first, then validate length:
const schema = v.pipe(
v.string(),
v.transform((s) => s.trim()),
v.minLength(1, "Cannot be empty"), // Checks the trimmed value
);For async transforms:
const schema = v.pipeAsync(
v.string(),
v.transformAsync(async (val) => {
const normalized = await api.normalize(val);
return normalized;
}),
);v.brand adds a nominal type tag without changing the runtime value:
const UserIdSchema = v.pipe(v.string(), v.uuid(), v.brand("UserId"));
type UserId = v.InferOutput<typeof UserIdSchema>;
// type UserId = string & { __brand: "UserId" }
function getUser(id: UserId): User { ... }
const id = v.parse(UserIdSchema, "uuid-string");
getUser(id); // Works — id is a branded UserId.
getUser("just a string"); // Type error.Brands give you the safety of a struct without the runtime cost.
Fix 6: Migrating From Zod
The mechanical translations:
| Zod | Valibot |
|---|---|
z.string() | v.string() |
z.string().min(3) | v.pipe(v.string(), v.minLength(3)) |
z.string().email() | v.pipe(v.string(), v.email()) |
z.number().int().positive() | v.pipe(v.number(), v.integer(), v.minValue(1)) |
z.boolean() | v.boolean() |
z.array(z.string()) | v.array(v.string()) |
z.object({...}) | v.object({...}) |
z.object({...}).partial() | v.partial(v.object({...})) |
z.union([...]) | v.union([...]) |
z.literal("x") | v.literal("x") |
z.enum(["a", "b"]) | v.picklist(["a", "b"]) |
z.string().optional() | v.optional(v.string()) |
z.string().nullable() | v.nullable(v.string()) |
z.string().transform(...) | v.pipe(v.string(), v.transform(...)) |
z.string().refine(...) | v.pipe(v.string(), v.check(...)) |
z.string().parse(x) | v.parse(v.string(), x) |
z.string().safeParse(x) | v.safeParse(v.string(), x) |
Type inference:
// Zod:
type User = z.infer<typeof UserSchema>;
// Valibot:
type User = v.InferOutput<typeof UserSchema>;
type UserInput = v.InferInput<typeof UserSchema>; // Before transformsPro Tip: For large codebases, codemod the mechanical parts and review by hand. The structural differences (chaining vs piping) make it impossible to fully automate.
Fix 7: Form Library Integration
TanStack Form:
import { useForm } from "@tanstack/react-form";
import { valibotValidator } from "@tanstack/valibot-form-adapter";
import * as v from "valibot";
const form = useForm({
defaultValues: { email: "" },
validatorAdapter: valibotValidator(),
});
<form.Field
name="email"
validators={{
onChange: v.pipe(v.string(), v.email("Invalid email")),
}}
>...</form.Field>React Hook Form:
import { useForm } from "react-hook-form";
import { valibotResolver } from "@hookform/resolvers/valibot";
import * as v from "valibot";
const schema = v.object({
email: v.pipe(v.string(), v.email()),
password: v.pipe(v.string(), v.minLength(8)),
});
const { register, handleSubmit, formState: { errors } } = useForm({
resolver: valibotResolver(schema),
});Conform (server-side validation):
import { parseWithValibot } from "@conform-to/valibot";
const submission = parseWithValibot(formData, { schema });All three libraries treat Valibot as a first-class peer of Zod. The adapters handle the conversion to the form library’s internal error format.
Fix 8: Bundle Size Verification
The main reason to use Valibot is bundle size. Verify it’s actually smaller:
# Bundle analyzer (Vite):
npx vite-bundle-visualizer
# esbuild's metafile:
esbuild src/index.ts --bundle --metafile=meta.json --outfile=bundle.js
npx esbuild-visualizer --metadata meta.jsonLook for valibot in the output. With proper tree-shaking, a schema using v.string(), v.email(), v.object(), v.pipe(), v.safeParse() should add ~2-3 KB minified.
If you see Valibot adding 10+ KB, your bundler isn’t tree-shaking. Common causes:
"sideEffects"not set correctly in your code (setfalsein yourpackage.json).- Using
import * as vversus named imports — in modern bundlers both tree-shake, but some legacy bundlers do better with named imports. - A wrapper package (e.g. an internal
@my-org/validationlib) that re-exports all of Valibot. Re-exports defeat tree-shaking unlesssideEffects: false.
Pro Tip: If bundle size doesn’t matter for your project (server-only validation, internal tools), Zod’s chaining ergonomics may be worth keeping. Valibot’s value proposition is “small bundle in the browser.”
Still Not Working?
A few less-obvious failures:
v.inferdoesn’t exist. Usev.InferOutput(orv.InferInput). The nameinferis reserved for the TypeScript keyword.- Discriminated unions inferred as plain union. Use
v.variant("type", [...])notv.union([...])for fields with a discriminator. v.pipe()with zero actions errors at runtime. Pipe needs at least one action after the base schema. For “no validation, just a string,” usev.string()directly.v.object({...}, v.string())rejects extra fields differently than Zod’s.strict(). The second arg is the “rest” schema — for “strict” behavior, usev.strictObject({...}).- Schemas defined in a loop reset. Each iteration creates a fresh schema. Cache outside if the schema is constant.
v.lazy(() => schema)infinite recursion. Make sure the recursive type has a base case (an optional field, av.literal(null)branch).- Form library shows nested path as “field.0.email” instead of “field[0].email”. The path representation differs by library. Map issue paths to your library’s expected format.
- Issue messages in non-English. Valibot’s built-in messages are English. For i18n, use
setGlobalConfig({ lang: "ja" })and provide message files, or write your own.
For related TypeScript validation and form issues, see Zod validation not working, TanStack Form not working, React Hook Form not working, and Pydantic validation error.
Solo developer based in Japan. Every solution is cross-referenced with official documentation and tested before publishing.
Was this article helpful?
Related Articles
Fix: TanStack Form Not Working — Field Types, Validators, Async Validation, and Subscription Re-renders
How to fix TanStack Form errors — field name not typed, zod/valibot validators not running, async onChange race conditions, listener not firing, array field state, and SSR with Server Actions.
Fix: Bun Bundler Not Working — Targets, Format, Externals, Macros, and Code Splitting
How to fix bun build errors — target (browser/bun/node) mismatch, format esm/cjs/iife, externals not respected, Bun macros at compile time, splitting and chunks, plugin API, and Bun.build vs CLI.
Fix: Bun Shell Not Working — $ Template Quoting, Pipes, Exit Codes, and Cross-Platform Scripts
How to fix Bun Shell errors — $ template auto-escape vs raw strings, piping with pipe() vs |, throws on non-zero exit, cwd/env scoping, glob expansion differences, and Windows path handling.
Fix: ESLint Flat Config Not Working — eslint.config.js, ignores, Plugins, and Migration
How to fix ESLint flat config errors — eslint.config.js not found, .eslintrc.json ignored after upgrade, ignores replacing .eslintignore, plugin object form, typescript-eslint integration, monorepo configs, and ESLINT_USE_FLAT_CONFIG.