valibot review
Valibot 1.4.2 validates unknown JavaScript values and derives TypeScript input and output types from the same schema. It composes small schema and action functions with `pipe()`: `parse()` throws, `safeParse()` returns issues, and `is()` narrows a value. Object constructors choose whether extra keys are removed, rejected, preserved, or validated by a rest schema. The current patch caches `Intl.Segmenter` during word-count checks and fixes `flatten()` and `intersect()` for keys such as `toString` that collide with `Object.prototype`. Valibot also implements Standard Schema for framework integration.
Valibot 1.4.2 installed in 1.1 seconds as one 2 MB package with zero direct dependencies and no audit findings; our full namespace import bundled to 14.6 KB gzipped. It is a strong browser-facing choice when schemas use static imports and Standard Schema, while Zod remains the safer pick for dependencies that demand Zod itself or teams committed to fluent methods.
We installed it
| Install | ✓ · 1.1s | 1 package on disk · 2 MB |
| Import | ✓ | ESM import works · require() works · ESM package with exports map |
| Browser | 14.6 KB | gzipped (83.8 KB minified), bundled with esbuild |
| Types | ✓ | TypeScript types bundled |
| Known vulns | 0 | 0 critical · 0 high · 0 moderate · 0 low (npm audit) |
Answers from our run
Does valibot install cleanly?
Yes. In a fresh container with an empty cache, npm install valibot finished in 1 seconds, leaving 1 package and 2 MB on disk. npm audit reported no known vulnerabilities.
How much does valibot add to a browser bundle?
14.6 KB gzipped (83.8 KB minified) when the whole package is bundled for the browser with esbuild. Importing only part of it is usually smaller.
Does valibot work with both ESM and CommonJS?
Yes. Both import 'valibot' and require('valibot') worked in Node 22 in our run. The package is published as ESM with an exports map.
Does valibot include TypeScript types?
Yes, type declarations ship inside the package, so no @types install is needed.
valibot or zod: which should you use?
zod: Use it when chained methods, community examples, and integrations that explicitly require Zod outweigh client bundle concerns. Valibot 1.4.2 installed in 1.1 seconds as one 2 MB package with zero direct dependencies and no audit findings; our full namespace import bundled to 14.6 KB gzipped.
When should you not use valibot?
The project is untyped JavaScript with async validation. TypeScript catches sync parser and async schema mismatches that plain JavaScript can let through incorrectly.
Use it if
- Browser code needs runtime schemas whose unused validation functions can be removed by a modern tree-shaking bundler.
- TypeScript should infer both the raw input and the transformed output from one executable definition.
- Form or API validation needs issue paths, flattened field errors, defaults, transformations, and cross-field checks.
- A framework accepts Standard Schema and can consume Valibot without a framework-specific adapter.
- The project is untyped JavaScript with async validation. TypeScript catches sync parser and async schema mismatches that plain JavaScript can let through incorrectly.
- The team strongly prefers chained schema methods. Valibot expresses constraints through nested functions and `pipe()` rather than fluent objects.
- Validation runs only on a server and Zod's larger set of examples or package-specific integrations would save more work than tree shaking.
- A dependency checks for Zod classes or demands a concrete Zod schema. Standard Schema helps only when the consumer accepts that shared contract.
- JSON Schema output or translated messages must ship from the same versioned package. Valibot publishes those concerns as separate companion packages.
Setup reality
We installed Valibot 1.4.2 without a cache in a fresh Node 22 Bookworm container. npm completed in 1.1 seconds and left one package using 2 MB. Valibot declares zero direct dependencies and one peer dependency, with 1,824 KB unpacked and an MIT license. npm audit reported zero known vulnerabilities at critical, high, moderate, and low severity. Bundled TypeScript declarations were present.
The package is ESM with an exports map, yet both require() and ESM import worked on our Node 22 box. TypeScript 5 or newer is the declared peer. Valibot needs no credentials or config file. The documentation commonly uses import * as v from 'valibot'; static member access lets modern bundlers remove unused functions, while dynamic access such as v[actionName] prevents that analysis. Our deliberate full-namespace esbuild import measured 83.8 KB minified and 14.6 KB gzipped, so measure the schema imports that production actually ships.
Migrating from Zod changes the expression shape. Constraints and transforms become functions inside pipe(), and wrappers implement optional, nullable, fallback, or async behavior. Plain object() drops unknown keys. Use strictObject() when a misspelled config key should fail, looseObject() when extras must survive, or objectWithRest() when every extra value needs validation. Version 1.4.2 specifically fixes issue flattening and intersection merging for object keys that shadow names such as toString.
Async checks require async schema constructors plus parseAsync() or safeParseAsync(). TypeScript rejects common sync and async pairings, but plain JavaScript does not provide that compile-time guard, so test each asynchronous path at runtime. Transforms execute in pipe order, which means the schema should validate the value again after a coercion such as Number. Global message setters mutate shared module state; set them once during startup instead of swapping locales between concurrent requests.
Patterns
Parse unknown login data define-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` describes data before transformations, while `InferOutput` describes the successful result. Static namespace access can still tree-shake.
Turn validation issues into field errors handle-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
}Check `success` before reading `output`; `flatten()` suits form state and dot paths identify individual nested controls.
Distinguish optional and nullable values optional-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 ... }A default can be a function for fresh values, and `exactOptional` treats an absent property differently from explicit `undefined`.
Pick an unknown-key policy object-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()Plain `object()` removes extras; `strictObject()` is usually the safer configuration schema because misspellings become issues.
Transform text and validate the result transform-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'); // 0Actions run from left to right. After `Number`, include `number()` and range checks for the converted value.
Select a union member by discriminator unions-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())]);`variant()` uses the shared `type` field; a general union tries members in order and can return less focused issues.
Attach a cross-field issue to one control cross-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'`forward()` moves the password comparison issue onto `confirm`, where a form can display it beside the input.
Await a database-backed validation async-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 rejectedUse async constructors and async parsers together. TypeScript catches the shown mismatch, but plain JavaScript may appear to succeed without output.
Alternatives
| Package | Registry | Pick it when |
|---|---|---|
| zod | npm | Use it when chained methods, community examples, and integrations that explicitly require Zod outweigh client bundle concerns. |
| arktype | npm | Use it when TypeScript-like schema expressions and its runtime checker fit the team's preferred authoring model. |
| superstruct | npm | Use it for a smaller established composable validator when Standard Schema and Valibot's transformations are unnecessary. |
More utils guides
lru-cache · ajv · type-fest · p-limit · find-up · js-yaml · the whole shelf →
How this guide is made: grounded in the library's documentation, release notes, changelog, and issue history, on a fixed rubric — not a hands-on install of every release. The 50 most-downloaded entries are additionally install-verified in clean containers. Corrections: contact the desk.

