A red Maine Coon cat resting beside a laptop displaying modular TypeScript code

Valibot: Schema Validation That Travels Light

The Orange Cat
The Orange Cat

If you have ever shipped a form validation library to the browser or the edge, you have probably winced at the bundle report. Validation is convenient, but it has traditionally arrived in one big box: import the library and the whole API tags along, whether you use three validators or thirty. Valibot was built to fix exactly that. It is a TypeScript-first schema validation library that lets you describe the shape of your data, validate unknown input against it at runtime, and infer static types from the same definition, all while letting your bundler throw away every feature you never touched.

It lives in the same neighborhood as Zod, Yup, and ArkType, and it solves the same core problems: verifying that API responses, form input, environment variables, or config files match your expectations, and keeping your runtime checks and compile-time types from drifting apart. What makes Valibot stand out is an aggressively modular, function-based API engineered from the ground up so that tools like Vite, esbuild, and Rollup can tree-shake it down to almost nothing. A typical login-form schema weighs in around 1.4 kB, where the equivalent in older Zod could run close to 18 kB. With zero runtime dependencies and roughly 10 million weekly downloads, it has graduated from clever upstart to mainstream choice.

Why It Feels Different

The headline idea behind Valibot is the move from method chaining to modular pipelines. Most validation libraries hang their validators off the schema object as methods. When you write z.string().email(), the email method lives on the string schema whether or not you call it, which means your bundler cannot safely remove it.

Valibot inverts this. Every schema and every validation or transformation is its own standalone, individually-imported function, and a pipe function composes them together.

import * as v from 'valibot';

const Email = v.pipe(
  v.string(),
  v.email(),
  v.endsWith('@example.com'),
);

Here string, email, endsWith, and pipe are all separate functions. If you never import v.email, it never enters your bundle. A pipeline always starts with a base schema and is followed by up to nineteen actions, each one a validation or a transformation. The tradeoff is honest: this reads a little heavier than a fluent chain, and you lean on the v. namespace rather than dot-autocomplete to discover validators. In exchange, your bundle scales with the features you use rather than the features the library offers.

What You Get in the Box

Valibot ships a full vocabulary of schemas and actions:

  • Schemas for the primitives and structures you expect: object, array, string, number, boolean, date, union, variant for discriminated unions, intersect, tuple, record, map, set, literal, picklist, optional, nullable, and lazy for recursive shapes.
  • Actions used inside pipe, including validations like email, minLength, maxLength, minValue, regex, url, uuid, and integer, plus transformations like transform, trim, toLowerCase, brand, and readonly.
  • Parsing functions in three flavors: parse which throws on failure, safeParse which returns a result object, and is which acts as a boolean type guard.
  • Type inference through InferOutput and InferInput, which matter once transforms change a type along the pipeline.
  • Async variants like parseAsync, safeParseAsync, and pipeAsync for validations that need to await.
  • Standard Schema compliance, so any tool that accepts a Standard Schema accepts a Valibot schema directly.

All of this works in any JavaScript runtime, whether that is Node, Deno, Bun, a browser, or an edge function.

Installation

Valibot has zero runtime dependencies, so installation is a single package.

npm install valibot

Or with yarn:

yarn add valibot

The convention in the community is to import the whole namespace as v, which keeps the function-based API readable.

Describing Your First Shape

The simplest thing you can do is define an object schema and let Valibot hand you the matching TypeScript type for free.

import * as v from 'valibot';

const LoginSchema = v.object({
  email: v.string(),
  password: v.string(),
});

type LoginData = v.InferOutput<typeof LoginSchema>;
// { email: string; password: string }

The schema is the single source of truth. LoginData is derived from it, so if you add a field to the schema, the type updates automatically and any code that constructs a LoginData will fail to compile until you account for it. There is no separate interface to keep in sync.

Adding Rules With Pipelines

A bare v.string() only checks that a value is a string. To layer on real validation, wrap the base schema in v.pipe and follow it with actions. Each action can carry its own custom error message, which is exactly what you want when these messages end up in front of users.

import * as v from 'valibot';

const LoginSchema = v.object({
  email: v.pipe(
    v.string('Your email must be a string.'),
    v.nonEmpty('Please enter your email.'),
    v.email('The email address is badly formatted.'),
  ),
  password: v.pipe(
    v.string('Your password must be a string.'),
    v.minLength(8, 'Your password must have 8 characters or more.'),
  ),
});

The actions run in order, so nonEmpty fires before email, and a user who submits an empty field sees the friendly prompt rather than a formatting complaint. Because each action is a separate import, a schema that only ever uses string, nonEmpty, email, and minLength pulls exactly those four validators into your bundle and nothing else.

Choosing How Failure Behaves

Valibot gives you two main ways to actually run a schema, and the right choice depends on how you want to handle invalid data. The throwing variant is concise when an error genuinely is exceptional.

// Throws v.ValiError on invalid input
const data = v.parse(LoginSchema, unknownInput);

For form handling and anywhere you expect bad input as a normal case, safeParse keeps control flow flat and gives you a typed result.

const result = v.safeParse(LoginSchema, unknownInput);

if (result.success) {
  result.output; // fully typed LoginData
} else {
  result.issues; // detailed array of what went wrong
}

The issues array describes precisely which path failed and why, which is what form libraries consume to attach errors to individual fields. When you only need a yes-or-no answer, v.is(LoginSchema, unknownInput) acts as a type guard and narrows the type inside the if block.

Transforming Data As You Validate

Pipelines are not limited to checking values; they can reshape them. This is where the distinction between input and output types becomes important. Consider a schema that accepts a string from a query parameter but produces a validated number.

const AgeSchema = v.pipe(
  v.string(),
  v.transform(Number),  // string -> number
  v.number(),
  v.minValue(18),
);

type In = v.InferInput<typeof AgeSchema>;   // string
type Out = v.InferOutput<typeof AgeSchema>; // number

The pipeline takes a string, converts it, re-validates that the result really is a number, then enforces a minimum. InferInput reflects what callers must provide, while InferOutput reflects what you receive after validation. A common early mistake is reaching for the wrong one; when in doubt, InferOutput is the type of your validated, transformed data and is the one you usually want.

Unions and Discriminated Variants

For values that can be one of several shapes, Valibot offers both a plain union and a smarter variant for discriminated unions.

const UnionSchema = v.union([v.string(), v.number()]);

const Shape = v.variant('type', [
  v.object({ type: v.literal('foo'), foo: v.string() }),
  v.object({ type: v.literal('bar'), bar: v.number() }),
]);

A plain union tries each branch until one matches. A variant instead reads the discriminator key, type in this example, and jumps straight to the matching branch. That makes validation faster and, more importantly, produces far clearer error messages: instead of a tangle of failures from every branch, you get the specific complaint from the branch the data was actually trying to be.

Slotting Into the Ecosystem

Because Valibot conforms to the Standard Schema specification, a shared interface co-authored alongside Zod and ArkType, it drops directly into a wide range of tools without adapters. Anything that accepts a Standard Schema, including tRPC, oRPC, TanStack Form, and Hono, accepts a Valibot schema as-is. On the form side it integrates with React Hook Form through a resolver, along with TanStack Form, Conform, VeeValidate, and the same author's headless Formisch form library. On the backend it pairs with Hono, tRPC, Drizzle ORM, NestJS, and next-safe-action for validated server actions. If you are migrating from Zod, the official docs include a Zod-to-Valibot guide, and the mental model carries over cleanly.

When To Reach For It

Valibot earns its place when bundle size is a real constraint: client-side React apps doing form validation, edge and serverless functions where cold-start time tracks bundle weight, and mobile or low-bandwidth contexts where every kilobyte is felt. It pairs first-class TypeScript inference with zero dependencies and slots neatly into the Standard Schema ecosystem. The honest tradeoffs are a slightly more verbose, function-heavy style than fluent chaining, weaker autocomplete discovery since validators are free functions, and a runtime that, while roughly twice as fast as older Zod, will not beat compiler-based validators like Typia or TypeBox that generate specialized code at build time.

What makes Valibot worth studying is that it took a thoroughly solved problem and re-examined it through a single lens, bundle size, with enough conviction to push the incumbent to respond with Zod Mini. If you like explicit, composable code and want validation that costs only what you use, Valibot is a genuinely lightweight way to keep your runtime and your types honest at the same time.