valibot
valibot is a schema library for validating unknown data at runtime and getting a TypeScript type out of the same declaration. You describe the shape with v.object({ email: v.pipe(v.string(), v.email()) }), then run v.parse(schema, input) to get typed data or throw, v.safeParse to get a result object with typed issues, or v.is as a type guard. Its distinguishing choice is that everything is a standalone function rather than a method on a builder object: there is no .email().min(5) chain, you compose with v.pipe instead. That makes the whole thing tree-shakable, so a schema touching ten functions pulls in ten functions rather than the 14.4 KB the full library weighs gzipped. It has no dependencies, TypeScript is an optional peer, and every schema exposes a Standard Schema v1 interface, which is how tRPC, TanStack Form, Hono and react-hook-form accept it with no adapter package.
The right pick when validation ships to the browser and every kilobyte is accounted for, and Standard Schema means that choice no longer costs you integrations. On a server, in TypeScript, the smaller ecosystem and the pipe syntax buy you a saving nobody will ever notice.
Use it if
- You ship validation to the browser and kilobytes are a real budget line: the modular design plus sideEffects: false means the bundler keeps only the functions you named, which is the entire reason this library exists
- You want your schemas accepted by other tools without glue: every schema carries a Standard Schema v1 interface reporting vendor 'valibot', so tRPC, TanStack Form, Hono's validator middleware and @hookform/resolvers take it directly
- Async checks belong in the schema: pipeAsync, checkAsync and safeParseAsync let a database uniqueness check live next to the length rule instead of in a separate layer of your handler
- You need error output in more than one shape: safeParse returns typed issues, flatten() gives per-field arrays for form state, getDotPath() gives 'address.city' for a single issue, and summarize() prints a readable multi-line block for a terminal
- You are targeting a runtime with no build step: zero dependencies, dual ESM and CJS builds, and TypeScript only an optional peer means it runs unchanged on Deno, Bun, Cloudflare Workers and plain Node
- Your codebase is plain JavaScript rather than TypeScript. Most of the safety here is compile-time: calling the synchronous v.parse on a schema built with pipeAsync returns undefined, and v.safeParse on the same schema reports { success: true } with no output field and no error thrown. TypeScript rejects both at compile time. JavaScript hands you a validation layer that silently approves everything
- You expect method chaining. There is no .min().optional(); every constraint, default and transform nests inside v.pipe, so z.string().email().min(5) becomes v.pipe(v.string(), v.email(), v.minLength(5)). It reads fine on a complex schema and it is noticeably more typing on a simple one, and it is the main cost of migrating an existing Zod codebase
- Bundle size is not your actual constraint. The saving is real in a browser bundle and irrelevant on a server or in a CLI, and what you pay for it is a smaller pool of examples, fewer Stack Overflow answers and LLM output that keeps writing Zod at you
- You depend on a library that wants a ZodType specifically. Standard Schema closes most of that gap, but anything doing instanceof checks or importing Zod's types directly will not take a valibot schema, and you will end up maintaining both
- Reserved JavaScript words get a trailing underscore: v.null_, v.undefined_, v.void_, v.function_, v.enum_. Harmless once you know, and a recurring source of 'v.enum is not a function' for anyone who does not, since editors autocomplete around it inconsistently
- You need JSON Schema conversion or translated messages. Both are separate packages, @valibot/to-json-schema and @valibot/i18n, and they run on their own version numbers (1.7.1 and 1.2.0 against the core's 1.4.2), so keeping them compatible is your job, not the resolver's
Setup reality
npm install valibot and that is genuinely it: no dependencies, TypeScript an optional peer at >=5, dual ESM and CJS builds with separate .d.mts and .d.cts types, and sideEffects: false. The first thing to settle is import style. Every page of the docs writes import * as v from 'valibot', which looks like it would drag the whole library in and does not: Rollup, esbuild and webpack all resolve static property access on a namespace import, so v.string() shakes exactly as well as a named import. What does break tree-shaking is dynamic access such as v[typeName](), so keep schema construction static if size is why you chose this. The second is the pipe model. Nothing chains, so optionals, defaults, transforms and refinements all nest, and a schema ported from Zod usually needs restructuring rather than a find and replace. The third, and the one that actually bites, is the sync and async split: a schema built with pipeAsync, objectAsync or checkAsync must be run with parseAsync or safeParseAsync. TypeScript enforces that. In JavaScript, v.parse on an async schema returns undefined and v.safeParse returns { success: true } with no output, so an untyped codebase gets a validator that approves everything and never complains. Last, note the repository now lives under the open-circle organisation rather than the author's personal account, so older links and any tooling pinned to the old path need updating.
Patterns
Declare a schema and get a type from itdefine-and-parse
import * as v from 'valibot';
const LoginSchema = v.object({
email: v.pipe(v.string(), v.email()),
password: v.pipe(v.string(), v.minLength(8)),
});
type LoginInput = v.InferInput<typeof LoginSchema>; // before transforms
type LoginData = v.InferOutput<typeof LoginSchema>; // after transforms
const data = v.parse(LoginSchema, await req.json()); // throws ValiError
if (v.is(LoginSchema, unknownValue)) {
unknownValue.email; // narrowed by the type guard
}InferInput and InferOutput differ the moment a pipe contains a transform or a default, so annotate function arguments with InferInput and return values with InferOutput. The namespace import is what the docs use and it still tree-shakes, because bundlers resolve static property access; only dynamic lookups like v[name]() defeat it.
Read issues without throwinghandle-errors
import * as v from 'valibot';
const result = v.safeParse(LoginSchema, { email: '', password: 'x' });
if (!result.success) {
v.flatten(result.issues);
// { nested: { email: ['Invalid email: Received ""'],
// password: ['Invalid length: Expected >=8 but received 1'] } }
result.issues.map((i) => v.getDotPath(i)); // ['email', 'password']
console.error(v.summarize(result.issues));
// x Invalid email: Received ""
// -> at email
} else {
result.output; // fully typed
}flatten() is what you want for form state, getDotPath() for pointing at a single field, and summarize() for terminal output. Note result.typed: it can be true while success is false, which means the shape matched but a validation action rejected the value, and that is the case where result.output still holds usable data.
Optional, nullable and default valuesoptional-and-defaults
import * as v from 'valibot';
const Settings = v.object({
theme: v.optional(v.picklist(['light', 'dark']), 'light'), // default
bio: v.optional(v.string()), // may be undefined
age: v.nullish(v.number()), // null or undefined
nickname: v.nullable(v.string()), // null allowed
id: v.exactOptional(v.string()), // key absent, not undefined
});
v.parse(Settings, {}); // { theme: 'light', nickname: undefined ... }The second argument to optional() is the fallback, and it can be a function if you need a fresh value per parse. exactOptional is the one people miss: optional() accepts an explicitly undefined value, exactOptional requires the key to be absent, which matters when you are validating a JSON patch. Reserved words carry a trailing underscore, so it is v.null_ and v.undefined_ when you need the bare schemas.
Decide what happens to unknown keysobject-strictness
import * as v from 'valibot';
v.parse(v.object({ a: v.string() }), { a: 'x', b: 1 });
// { a: 'x' } unknown keys are dropped
v.safeParse(v.strictObject({ a: v.string() }), { a: 'x', b: 1 });
// issue: Invalid key: Expected never but received "b"
v.parse(v.looseObject({ a: v.string() }), { a: 'x', b: 1 });
// { a: 'x', b: 1 } unknown keys kept as-is
v.parse(v.objectWithRest({ a: v.string() }, v.number()), { a: 'x', b: 2 });
// { a: 'x', b: 2 } unknown keys validated against v.number()The default silently discards unknown keys, which is the right behaviour for an API boundary and the wrong one when a client typo should be an error. Use strictObject for internal configuration files where a misspelled key means a setting is being ignored, and objectWithRest for dictionaries with a few known fields.
Coerce input and change the output typetransform-and-coerce
import * as v from 'valibot';
// query string to number, then validate the number
const Page = v.pipe(v.unknown(), v.transform(Number), v.number(), v.minValue(1));
v.parse(Page, '42'); // 42
// string to Date
const Day = v.pipe(v.string(), v.isoDate(), v.transform((s) => new Date(s)), v.date());
v.parse(Day, '2026-08-06'); // Date
// never fail: substitute a value instead
v.parse(v.fallback(v.number(), 0), 'nope'); // 0A pipe runs left to right, so put the transform before the schema that checks the transformed value or you are validating the wrong thing. transform() is where InferInput and InferOutput diverge. fallback() swallows every issue for that schema, so use it for genuinely optional configuration and never for user input you were supposed to reject.
Discriminated unions that give useful errorsunions-and-variants
import * as v from 'valibot';
const Event = v.variant('type', [
v.object({ type: v.literal('click'), x: v.number(), y: v.number() }),
v.object({ type: v.literal('key'), key: v.string() }),
]);
v.parse(Event, { type: 'key', key: 'a' });
v.safeParse(Event, { type: 'scroll' }).issues[0].message;
// 'Invalid type: Expected ("click" | "key") but received "scroll"'
// no shared discriminator: falls back to trying each in order
const Id = v.union([v.string(), v.pipe(v.number(), v.integer())]);Reach for variant whenever the members share a literal key. union tries every option and reports the issues from all of them, which produces a wall of text on a five-member union, while variant reads the discriminator first and reports one clear message. variant members must be object schemas.
Compare two fields and attach the error to onecross-field-validation
import * as v from 'valibot';
const Register = v.pipe(
v.object({
password: v.pipe(v.string(), v.minLength(8)),
confirm: v.string(),
}),
v.forward(
v.partialCheck(
[['password'], ['confirm']],
(input) => input.password === input.confirm,
'Passwords do not match',
),
['confirm'],
),
);
const r = v.safeParse(Register, { password: 'aaaaaaaa', confirm: 'b' });
v.getDotPath(r.issues[0]); // 'confirm'partialCheck runs even when other fields failed, as long as the paths it names are valid, which is what you want on a form. forward() moves the issue onto a specific field so flatten() puts the message under 'confirm' instead of in the top-level root array. Use plain check() when the error genuinely belongs to the whole object.
Validate against a database, and never mix the two worldsasync-validation
import * as v from 'valibot';
const Signup = v.objectAsync({
email: v.pipeAsync(
v.string(),
v.email(),
v.checkAsync(async (e) => !(await db.userExists(e)), 'Email already taken'),
),
});
const result = await v.safeParseAsync(Signup, body);
// DANGER in plain JavaScript, a compile error in TypeScript:
v.parse(Signup, body); // returns undefined
v.safeParse(Signup, body); // { success: true }, no output, nothing rejectedAsync schemas need the async runners, and the failure mode when you forget is silent rather than loud: the sync entry points return undefined and report success. TypeScript catches this, which is a large part of why this library is a poor fit for untyped codebases. Keep async checks last in the pipe so a malformed value never reaches your database.
Override error messages per rule or globallycustom-messages
import * as v from 'valibot';
// per action, last argument
v.pipe(v.string(), v.minLength(8, 'Password must be at least 8 characters'));
// for every use of one action
v.setSpecificMessage(v.minLength, 'Too short');
v.deleteSpecificMessage(v.minLength);
// for everything that has no more specific message
v.setGlobalMessage((issue) => `Invalid input: ${issue.type}`);
v.deleteGlobalMessage();
// per schema
v.setSchemaMessage('Bad payload');Precedence runs from most specific to least: the argument on the action, then setSpecificMessage, then setSchemaMessage, then setGlobalMessage, then the built-in English text. The set* functions write to module-level state, so calling them per request in a server leaks between requests. Set them once at startup, or use @valibot/i18n for real translations.
Stop at the first error when you only need onecontrol-issue-collection
import * as v from 'valibot';
const Schema = v.object({ a: v.string(), b: v.string() });
v.safeParse(Schema, { a: 1, b: 2 }).issues.length; // 2
v.safeParse(Schema, { a: 1, b: 2 }, { abortEarly: true }).issues.length; // 1
// stop after the first failing action inside one pipe
const P = v.pipe(v.string(), v.minLength(5), v.email());
v.safeParse(P, 'a', { abortPipeEarly: true }).issues.length; // 1
// bake the config into the schema
const Fast = v.config(Schema, { abortEarly: true });abortEarly stops the whole parse, abortPipeEarly stops only the current pipe and keeps validating sibling fields. Forms want both off so the user sees every problem at once; an API boundary rejecting hostile input wants abortEarly on, because collecting every issue on a deeply nested payload is work an attacker gets for free.
Describe a tree with lazyrecursive-schemas
import * as v from 'valibot';
type TreeNode = {
name: string;
children: TreeNode[];
};
const Node: v.GenericSchema<TreeNode> = v.object({
name: v.string(),
children: v.optional(v.array(v.lazy(() => Node)), []),
});
v.parse(Node, { name: 'a', children: [{ name: 'b' }] });
// { name: 'a', children: [{ name: 'b', children: [] }] }TypeScript cannot infer a recursive type through lazy(), so you write the type by hand and annotate the schema with v.GenericSchema<T>; skip the annotation and you get an implicit-any circular reference error. lazy() re-evaluates its callback on every parse of that node, so keep the callback a bare reference rather than building a new schema inside it.
Hand the schema to a framework with no adapterstandard-schema
import * as v from 'valibot';
import { valibotResolver } from '@hookform/resolvers/valibot';
import { useForm } from 'react-hook-form';
const Schema = v.object({ email: v.pipe(v.string(), v.email()) });
useForm({ resolver: valibotResolver(Schema) });
// or read the Standard Schema interface directly
Schema['~standard'].version; // 1
Schema['~standard'].vendor; // 'valibot'
const out = await Schema['~standard'].validate(input);Anything that accepts Standard Schema v1, including tRPC, TanStack Form and Hono's validator, takes a valibot schema unchanged, which removes the adapter package that used to be needed for each. The ~standard property is intended for library authors; in your own code call v.parse or v.safeParse, which give better typing and the issue helpers.
Alternatives
| Package | Registry | Pick it when |
|---|---|---|
| zod | npm | You want the default choice with the most examples, integrations and community answers, and bundle size is not what you are optimising |
| arktype | npm | You would rather write schemas as TypeScript-like type strings and want the fastest runtime validation in the group |
| @sinclair/typebox | npm | Your schemas need to be real JSON Schema documents for OpenAPI or Ajv, not an object you convert afterwards |