Fix: Valibot Not Working — v.pipe Syntax, Error Messages, Async Validation, and Zod Migration
Part of: JavaScript & TypeScript Errors
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.
The deeper reason the migration trips people up is that Zod and Valibot encode validation in two different mental models. Zod uses a builder pattern: each method returns a new schema that carries the previous one’s state, so z.string().min(3).email() reads top-to-bottom like an English sentence. Valibot inverts this — every operation is a discrete value, and v.pipe is the composition primitive. Once it clicks, you stop reaching for “method on a schema” and start composing pipelines, but until then most translation errors are caused by trying to mix the two styles.
The async distinction is the other common cliff. Valibot draws a hard line between sync schemas and async schemas, and the line is contagious upward: one v.checkAsync deep inside an object forces the wrapping object to be v.objectAsync, and the runner at the very top must be parseAsync or safeParseAsync. Zod handles this implicitly by detecting promises at runtime; Valibot exposes the distinction in the types so the bundler can prove that purely sync schemas never pull async machinery into the bundle. That is the trade — extra ceremony in exchange for the small bundle that drew you to Valibot in the first place.
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.”
Valibot vs Zod, Yup, Joi, ArkType, and Effect Schema: Picking a Validation Library
Valibot enters a crowded field. The right choice depends on whether you optimize for bundle size, runtime ergonomics, ecosystem reach, or type-level inference.
Zod is the de facto standard for TypeScript schema validation today. The chained API reads naturally, the ecosystem (TanStack Form, tRPC, React Hook Form, Astro Actions) treats it as the default, and the documentation is exhaustive. The cost is bundle size: a typical Zod-using bundle adds 12–14 KB minified after gzip even when you only use a few helpers. For server-side code that ships nothing to the browser, the size cost is irrelevant and Zod wins on ergonomics alone.
Valibot is the bundle-conscious alternative. The functional composition model — v.pipe(v.string(), v.email()) instead of z.string().email() — exists specifically so unused validators get tree-shaken. A typical browser bundle using only a handful of helpers comes in at 2–3 KB. Use Valibot when you ship validators to the edge (Cloudflare Workers, Vercel Edge), embed them in a marketing site, or have a hard performance budget for the JS bundle.
Yup is the legacy choice. It predates the TypeScript-first wave and still ships in a lot of Formik codebases. Its type inference works but is weaker than Zod or Valibot, async support feels bolted on, and the bundle is large. Stick with Yup only when you inherit a codebase already using it heavily.
Joi was the Node.js validation library for a long time and is still common in older Express APIs and Hapi servers. It is excellent at expressing complex validation rules with clear error messages, but it has no real type inference — you write the type separately and trust that the runtime schema matches. Use Joi when you are validating Node-side input where types are not the priority.
ArkType is the type-level extremist. It infers TypeScript types from runtime schemas with parsing-grade fidelity and produces structurally identical types and runtime checks from a single source. The bundle is small, performance is excellent, and the API is concise. The cost is that ArkType is newer than Zod and Valibot, so ecosystem integration is thinner. Choose it when you want a single source of truth across compile-time and runtime and you are comfortable being slightly off the beaten path.
Effect Schema is the choice when you are already invested in Effect. It composes with Effect’s error handling, dependency injection, and concurrency primitives, so validation errors flow into the same Effect pipeline as the rest of your business logic. Outside an Effect codebase, the integration cost outweighs the benefit.
Rule of thumb: stay on Zod when you do not care about bundle size, move to Valibot when the bundle matters, and reach for ArkType when type-level precision is the priority. Yup, Joi, and Effect Schema are situational.
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. v.pickandv.omittypes look loose. Pass the keys asconst-asserted tuples (["email", "name"] as const) so TypeScript narrows the result to the picked subset instead of widening to the full object.v.fallbackswallows errors silently.v.fallback(schema, defaultValue)returns the default whenever the input fails to parse — useful for tolerant config readers, dangerous in form validation. Reserve it for entry points where loss of fidelity is acceptable.- Server actions get the input as
FormData, not JSON. Valibot expects parsed values. Convert withObject.fromEntries(formData)and run the validator on the resulting object, or use the Conform integration which handles the conversion.
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: pnpm Catalog Protocol Not Working — Cannot Find Catalog, Resolution Errors, and Lockfile Issues
Fix pnpm 9.5+ catalog protocol errors — Cannot find catalog default, ERR_PNPM_CATALOG_ENTRY_INVALID_SPEC, stale lockfile state, and tool incompatibility with catalog: references in monorepos.
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.