Skip to content

Fix: Valibot Not Working — v.pipe Syntax, Error Messages, Async Validation, and Zod Migration

FixDevs ·

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 ValiError

Or 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 with v.pipe(schema, ...actions). This is what makes Valibot tree-shakable to ~1-3 KB versus Zod’s ~13 KB.
  • parse throws; safeParse returns a result. Match the call style to your error handling preference. parse is shorter when you have a try/catch up the stack; safeParse is cleaner for forms.
  • Async schemas need async parse. If any node in the schema uses Async variants (v.checkAsync, v.transformAsync), you must call v.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. Returns true for valid, false for 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:

ZodValibot
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 transforms

Pro 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.json

Look 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 (set false in your package.json).
  • Using import * as v versus 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/validation lib) that re-exports all of Valibot. Re-exports defeat tree-shaking unless sideEffects: 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.infer doesn’t exist. Use v.InferOutput (or v.InferInput). The name infer is reserved for the TypeScript keyword.
  • Discriminated unions inferred as plain union. Use v.variant("type", [...]) not v.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,” use v.string() directly.
  • v.object({...}, v.string()) rejects extra fields differently than Zod’s .strict(). The second arg is the “rest” schema — for “strict” behavior, use v.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, a v.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.

F

FixDevs

Solo developer based in Japan. Every solution is cross-referenced with official documentation and tested before publishing.

Was this article helpful?

Related Articles