mrkeyoor.com_
Thu 06 Aug 10:56 UTC
npmCLI & Toolingupdated 06 Aug 2026

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.

Verdict

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.

API stability4/51.0 shipped in early 2025 and the 1.x line has added functions rather than renaming them, with to-json-schema and i18n kept on separate version numbers so core releases stay clean; the deduction is that the long 0.x period broke things often enough that a lot of published examples no longer compile, and newer helpers such as summarize still carry a beta marker in the type definitions
Docs5/5valibot.dev carries a per-function API reference generated from source, task-based guides for parsing, piping and methods, an explicit migration guide from Zod, and a linked thesis explaining why the design is shaped this way; the runtime error strings are unusually good too, printing both expected and received values so a failure is readable without a debugger
Maintenance4/5Pushed 2026-08-06, with 1.4.0 through 1.4.2 released between May and June 2026 and companion packages updated alongside, now under the open-circle organisation rather than a personal account; 82 genuinely open issues out of 167 open issues and PRs is a visible backlog, which is the trade for a fast-moving project
Ecosystem4/5About 16.7M downloads a week, Standard Schema support that gets it into tRPC, TanStack Form, Hono and @hookform/resolvers with no adapter, and official packages for JSON Schema and translations; it is still clearly second to Zod for tutorials, generated code and library-specific integrations, which is what you feel when something goes wrong

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
Skip it if

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');   // 0

A 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 rejected

Async 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

PackageRegistryPick it when
zodnpmYou want the default choice with the most examples, integrations and community answers, and bundle size is not what you are optimising
arktypenpmYou would rather write schemas as TypeScript-like type strings and want the fastest runtime validation in the group
@sinclair/typeboxnpmYour schemas need to be real JSON Schema documents for OpenAPI or Ajv, not an object you convert afterwards