Valibotは、TypeScript向けのスキーマ検証ライブラリです。v.string() や v.email() といった小さな関数を v.pipe() でつないでスキーマを作り、使った関数だけがバンドルに入ります。手元でログインフォームの検証をesbuildでまとめると、gzip後はValibot 1.5.0が1.66kB、Zod 4.6.7が26.0kBでした。
この記事は2026年9月9日公開のv1.5.0で動くコードだけを載せています。日本語のValibot記事には、v0.31.0で廃止された string([email()]) の書き方がまだ多く残っています。後半ではZod・Zod Mini・ArkTypeと検証速度も測り、どの条件でValibotを選ぶかを決めます。
まとめ
- Valibotの仕組み:検証ルールを独立した関数に分け、
v.pipe(v.string(), v.email())の形で組み立てる。使わない関数はtree-shakingで落ちるため、ログインフォーム程度ならgzip後1〜2kBで済む。 - 基本API:失敗で例外を投げる
v.parse()、結果オブジェクトを返すv.safeParse()、型ガードのv.is()。v.checkAsync()などを含むスキーマはv.pipeAsync()で組み、v.safeParseAsync()で呼ぶ。 - Zodとの違い:同じフォームでバンドルはZodの約16分の1、Zod Miniの約3.5分の1。検証速度は正常値ならZodとほぼ同じで、入れ子のオブジェクトではZodのほうが速い。不正値ではValibotが速い。ただしZodとの差は1回あたり数マイクロ秒以下なので、フォーム用途では配信サイズの差のほうが効く。
- 選び方:ブラウザに配信するフォーム検証ならValibot。サーバーだけで使う検証なら、Zodの周辺ツールを捨ててまで移る理由は薄い。
- 落とし穴:
v.maxLength()はUTF-16コード単位で数えるので「𠮷」は2、絵文字は種類や組み合わせによって異なる数になる。人が数える文字数で制限するならv.maxGraphemes()。非同期スキーマを同期のv.safeParse()に渡すと、JavaScriptでは検証結果を待たずにsuccess: trueが返る。
Valibotの仕組みとバンドルが小さくなる理由
Zodでは z.string() が返すオブジェクトに .min()・.email()・.trim() などのメソッドがまとめて付いています。使っていないメソッドもオブジェクトの一部なので、バンドラは削れません。Valibotは v.string() と v.email() を別の関数として書き出し、importしていない関数はバンドルに入りません。
Valibotは依存パッケージを持たず、公式のREADME は「どのJavaScript環境でも動く」としています。npmの週間ダウンロードは3,199万回(2026年10月23〜29日)で、同じ期間のZodの4億4,297万回の約14分の1です。GitHubのスターは9,051件(2026年10月31日時点)。2025年3月19日にv1.0が出てからは、マイナー版で関数を足す形でAPIが安定しています。
Valibotのインストールとバージョン確認
npm install valibotと版の確かめ方
型定義は本体に入っているので、@types パッケージは要りません。npmのvalibot の最新は1.5.0です。
npm install valibot
# pnpm add valibot / yarn add valibot / bun add valibot でも同じ
# プロジェクトに入っている版を確かめる
npm list valibot
# [email protected]
[email protected] が入っていると、この記事のコードは型エラーになります。importは名前空間の形(import * as v from 'valibot')を使ってください。string・object・number といった関数名がアプリ側の変数名とぶつかるのを避けられ、tree-shakingも個別importと同じく効きます。
parse・safeParse・isの使い分け
import * as v from 'valibot';
const EmailSchema = v.pipe(v.string(), v.email());
// parse:失敗すると ValiError を投げる
const email = v.parse(EmailSchema, '[email protected]'); // string 型になる
// safeParse:例外を投げず、結果オブジェクトを返す
const input: unknown = 'invalid-email';
const result = v.safeParse(EmailSchema, input);
if (result.success) {
console.log(result.output); // 検証を通った値
} else {
console.log(result.issues); // 失敗の詳細(配列)
}
// is:真偽値だけを返す型ガード
if (v.is(EmailSchema, input)) {
// このブロックの中では input が string 型
}
フォームの入力ミスは想定内の分岐なので、safeParse で受けて issues を画面に出します。APIサーバーで「不正なリクエストは400で返す」と決めているなら、parse の例外をエラーハンドラでまとめて拾うほうが短く書けます。is は変換(v.transform())の結果を返さないので、加工後の値が必要な場合はparseまたはsafeParseを使います。
スキーマ定義とpipe構文の書き方
オブジェクト・配列・ネストの書き方
const UserSchema = v.object({
name: v.pipe(v.string(), v.minLength(1, '名前を入力してください')),
age: v.pipe(v.number(), v.minValue(0), v.maxValue(150)),
email: v.pipe(v.string(), v.email('メールアドレスの形式が正しくありません')),
tags: v.array(v.string()),
address: v.object({
zip: v.pipe(v.string(), v.regex(/^\d{3}-\d{4}$/)),
city: v.string(),
}),
});
type User = v.InferOutput<typeof UserSchema>;
// { name: string; age: number; email: string; tags: string[]; address: { zip: string; city: string } }
v.pipe() の1番目には型を決めるスキーマ(v.string() など)を置き、2番目以降に条件を足すアクション(v.minLength() など)を並べます。v.object() は定義に無いキーを出力から落とします。余分なキーをエラーにしたいなら v.strictObject()、残したいなら v.looseObject() を使います。
optional・nullable・nullishと既定値
| 書き方 | 通す値 | 使う場面 |
|---|---|---|
v.optional(v.string()) |
string / undefined | キーが無くてもよい項目 |
v.nullable(v.string()) |
string / null | DBのNULL列 |
v.nullish(v.string()) |
string / null / undefined | どちらも来る項目 |
v.optional(v.string(), 'guest') |
string(無ければ ‘guest’) | 既定値を入れる項目 |
APIのレスポンスを検証するときは、サーバーがキーを省くのか null を返すのかを先に確かめてください。v.optional() にした項目に null が来ると、本番の実データだけが弾かれます。
checkとforwardで項目をまたぐ検証(Zodのrefine相当)
Zodの .refine() にあたるのが v.check() です。パスワードの確認欄のように複数の項目をまたぐ検証は、オブジェクト全体のpipeに置き、v.forward() でエラーを出したい項目へ付け替えます。
const SignUpSchema = v.pipe(
v.object({
email: v.pipe(v.string(), v.email()),
password: v.pipe(v.string(), v.minLength(8)),
confirm: v.string(),
}),
v.forward(
v.partialCheck(
[['password'], ['confirm']],
(input) => input.password === input.confirm,
'パスワードが一致しません'
),
['confirm']
)
);
ここで v.check() ではなく v.partialCheck() を使うのには理由があります。手元で email に数値を入れて試すと、v.check() 版は email の型エラーだけを返し、一致確認は走りませんでした。オブジェクトのどこかに型の誤りがあると、全体を受け取る v.check() は実行されないためです。v.partialCheck() は指定した password と confirm が正しい型なら実行されるので、フォームでは両方のエラーが同時に出ます。v.forward() を省くとエラーがオブジェクト全体に付き、どの入力欄に赤字を出すか決められません。
transformと型推論:InferInputとInferOutputの違い
// 前後の空白を落としてから数値にする
const CountSchema = v.pipe(
v.string(),
v.trim(),
v.nonEmpty('数量を入力してください'),
v.toNumber(),
v.integer()
);
type CountInput = v.InferInput<typeof CountSchema>; // string
type CountOutput = v.InferOutput<typeof CountSchema>; // number
v.nonEmpty() を外すと、空欄や空白だけの入力が0として通ります。手元のv1.5.0で v.transform(Number) 版と v.toNumber() 版の両方を実行すると、' ' も '' も success: true・出力0でした。JavaScriptの Number('') が0を返すためで、数量や金額の欄では、未入力を0として後続処理に渡してしまうおそれがあります。
変換を挟むと入力型と出力型がずれます。フォームの値の型には InferInput、検証後に使う値の型には InferOutput を使ってください。検証済みの値を未検証の文字列と区別したいなら、v.brand() で Branded Types(ブランド型) を付けると、検証を通していない値の混入を型検査で止められます。
非同期検証のpipeAsync・safeParseAsyncによる実装
メールアドレスの重複確認のようにデータベースやAPIを呼ぶ検証は、v.checkAsync() を使い、スキーマ全体を v.pipeAsync() で組みます。呼び出しも v.safeParseAsync() に変えます。
const RegisterSchema = v.pipeAsync(
v.string(),
v.email(),
v.checkAsync(async (email) => !(await isTaken(email)), 'このメールアドレスは登録済みです')
);
const result = await v.safeParseAsync(RegisterSchema, input);
注意したいのは同期の v.safeParse() に渡した場合です。TypeScriptなら Type 'true' is not assignable to type 'false' の型エラーで止まります。ところが型検査の無いJavaScriptや as any 経由で渡すと、例外は出ません。手元で必ず失敗する v.checkAsync(async () => false) を同期の v.safeParse() に渡したところ、success: true・output: undefined が返りました。v.checkAsync() の関数は呼び出しの直後にはまだ実行されておらず、次のイベントループで1回実行されましたが、その失敗は結果に反映されません。非同期の検証結果を待たずに、通ったように見えてしまいます。JavaScriptのプロジェクトやスキーマを動的に組み立てる箇所では、schema.async が true かどうかで呼び出しを分けてください。
日本語フォームの文字数制限:maxLengthとmaxGraphemes
v.maxLength() が数えるのはJavaScriptの string.length、つまりUTF-16の単位です。人名で使われる「𠮷(つちよし)」は見た目が1文字でも2と数えます。絵文字の値は種類や組み合わせによって異なります。v1.5.0では、Unicodeのコードポイント単位で数える v.maxCodePoints() などが追加されました(v1.5.0のリリースノート)。手元で数えた結果が次の表です。
| 入力 | 見た目の文字数 | maxLengthの数 | maxCodePointsの数 | maxGraphemesの数 |
|---|---|---|---|---|
| あいうえお | 5 | 5 | 5 | 5 |
| 𠮷野家🍣 | 4 | 6 | 4 | 4 |
| 👨👩👧(家族の絵文字) | 1 | 8 | 5 | 1 |
「20文字以内」と画面に書いている入力欄なら、v.maxGraphemes(20) が画面の表示と一致します。内部で Intl.Segmenter を使うので、古いブラウザを対象にするなら動作を確かめてください。DBのカラム長に合わせたいときは、そのDBの数え方を確認して選びます。MySQLの VARCHAR と utf8mb4 の組み合わせならコードポイント単位なので v.maxCodePoints() が合います。
v1.5.0では v.email() も変わり、Unicodeの大文字小文字の変換で通っていた非ASCII文字を弾くようになりました。手元では テスト@example.com も user@例え.jp も不合格です。v.rfcEmail() でも同じ2件は不合格でした。日本語ドメインのアドレスを受け付ける業務なら、v.check() で受け付ける条件を自分で書く必要があります。
エラー処理とメッセージの日本語化
flatten・summarizeによるエラーの整形
const result = v.safeParse(UserSchema, input);
if (!result.success) {
const flat = v.flatten<typeof UserSchema>(result.issues);
// flat.nested.email → ['メールアドレスの形式が正しくありません']
// flat.nested['address.zip'] → ['Invalid format: Expected ...']
// flat.root → オブジェクト全体へのエラー
console.log(v.summarize(result.issues));
// × メールアドレスの形式が正しくありません
// → at email
}
v.flatten() はネストした項目を address.zip のようにドットでつないだキーにまとめます。v.summarize() はv1.1で入った関数で、ログに出しやすい複数行の文字列を返します。
@valibot/i18nによるエラーメッセージの日本語化
公式の @valibot/i18n(1.3.0)には日本語を含む34の言語コードの訳が入っています。言語ごと、関数ごとに読み込めるので、使う言語の分だけがバンドルされます。
npm install @valibot/i18n
import * as v from 'valibot';
import '@valibot/i18n/ja'; // 日本語の訳をまとめて登録
v.setGlobalConfig({ lang: 'ja' });
// → 「無効なメールアドレス: "x" を受け取りました」
// 業務向けの言い回しは関数単位で上書きする
v.setSpecificMessage(v.minLength, (issue) => `${issue.requirement}文字以上で入力してください`, 'ja');
同梱の訳は「無効な長さ: >=3 を期待しましたが、 1 を受け取りました」のような開発者向けの文です。利用者に見せる画面では、各アクションの最後の引数に文言を渡すか、v.setSpecificMessage() で上書きするのが現実的です。スキーマの型エラーと条件のエラーに同じ文言を出したいなら、v1.1で入った v.message(schema, '入力内容を確認してください') がまとめて上書きします。
ただし @valibot/i18n/ja を読み込んで言語を日本語にしている場合、v.message() は効きません。手元では、読み込んだだけ(言語は英語のまま)なら v.message() の文言が出ましたが、setGlobalConfig({ lang: 'ja' }) にすると、v.message() で付けた文言は消え、同梱の 無効なメールアドレス: "x" を受け取りました が返りました。Valibotはエラー文を「各アクションの引数 → setSpecificMessage などの登録 → v.message() → 全体の既定」の順に探し、@valibot/i18n はアクションごとの登録として入るためです。i18nを読み込むアプリで固定の文言を出したい項目は、v.email('入力内容を確認してください') のように引数で渡してください。
ZodとValibotの違いと選び方
書き方の違い:pipe関数とメソッドチェーン
// Valibot
const LoginSchema = v.object({
email: v.pipe(v.string(), v.nonEmpty('入力してください'), v.email('形式が正しくありません')),
password: v.pipe(v.string(), v.minLength(8, '8文字以上で入力してください')),
});
// Zod 4
const ZodLoginSchema = z.object({
email: z.email('形式が正しくありません'),
password: z.string().min(8, '8文字以上で入力してください'),
});
Zod 4では z.string().email() が非推奨になり、z.email() に変わりました(型定義の @deprecated 注記)。Zodにも .pipe() がありますが、役割は違います。Zodの .pipe() は「変換した値を別のスキーマで検証し直す」ためのもので、z.string().transform(Number).pipe(z.number().int()) のように書きます。Valibotの v.pipe() はスキーマと条件をまとめる基本の書き方で、ほぼすべてのスキーマに出てきます。Zod側の書き方は Zodの使い方入門 にまとめています。
バンドルサイズの実測:Zod 4.6・Zod Mini・ArkType
公式の比較ページと同じログインフォーム(メールアドレスとパスワード・エラー文も同じ)をValibot・Zod・Zod Miniで書き、safeParse を1回呼ぶコードをesbuild 0.28.2でバンドル・minifyしてgzipしました。ArkTypeには safeParse が無いので、メールアドレスと8文字以上のパスワードを型構文で定義し、スキーマを関数として直接呼んでいます(エラー文は既定のまま)。esbuildは --bundle --minify --format=esm、gzipは既定の圧縮レベルです。
| ライブラリ | minify後 | gzip後 | Valibot比 |
|---|---|---|---|
| Valibot 1.5.0 | 4.0kB | 1.66kB | 1倍 |
| Zod 4.6.7(zod/mini) | 15.8kB | 5.73kB | 約3.5倍 |
| Zod 4.6.7(zod) | 86.3kB | 26.0kB | 約16倍 |
| ArkType 2.2.9 | 161.8kB | 50.6kB | 約30倍 |
公式の比較ページ はesbuildでValibot 1.37kB、Zod 17.7kB、Zod Mini 6.88kBと載せています。道具と版で数字は変わっても、Valibotが最小でZod Miniが中間という並びは同じです。
検証速度の実測:正常入力・不正入力による差
Node.js v26.5.0(Intel Core i9-9880H)で、1回の検証にかかる時間を測りました。Valibot・Zod・Zod MiniではsafeParse、ArkTypeではスキーマ関数の直接呼び出しを使っています。ログインフォームは上と同じスキーマ、入れ子のオブジェクトは名前・年齢・タグの配列・住所(郵便番号・市区町村・緯度経度)を持つスキーマです。100万回の実行から1回あたりの時間を求める測定を、条件ごとに6〜14回繰り返しました。測定したマシンで別の処理が動いていて値がばらついたため、表には正常な入力・不正な入力それぞれの最も短かった値を載せています。絶対値ではなく、ライブラリ間の大小関係の目安として見てください。
| スキーマ | ライブラリ | 正常な入力 | 不正な入力 |
|---|---|---|---|
| ログインフォーム | Valibot 1.5.0 | 0.37µs | 0.56µs |
| ログインフォーム | Zod 4.6.7 | 0.40µs | 1.88µs |
| ログインフォーム | Zod Mini | 0.58µs | 2.78µs |
| ログインフォーム | ArkType 2.2.9 | 0.08µs | 12.9µs |
| 入れ子のオブジェクト | Valibot 1.5.0 | 1.12µs | 3.01µs |
| 入れ子のオブジェクト | Zod 4.6.7 | 0.71µs | 4.46µs |
| 入れ子のオブジェクト | Zod 4.6.7(JITなし) | 2.41µs | 6.78µs |
正常な入力では、ログインフォームのValibotとZodはほぼ同じでした。入れ子のオブジェクトではZodが約1.6倍速くなります。Zod 4は検証コードを new Function で組み立てて速くしているためです。Zod 4.6.7のソースは、jitless の設定、実行環境のユーザーエージェントがCloudflareかどうか、new Function が実際に使えるかを順に調べ、どれかで使えないと判断すると通常の処理に切り替えます。つまりCloudflare Workersや、eval を禁じるContent Security Policyの下ではこの高速化は使われません。z.config({ jitless: true }) で同じ状態にすると、入れ子のオブジェクトでZodは2.41µsになり、Valibotの約2倍遅くなりました。Valibotはもともと new Function を使わないので、このような処理の切り替えがありません。不正な入力では、ログインフォームでも入れ子のオブジェクトでもValibotが最も速く、ArkTypeは正常な入力の約160倍の時間がかかりました。
どれもマイクロ秒単位で、フォーム送信やAPIリクエスト1件の検証では体感できる差になりません。判断材料はバンドルサイズです。
Valibotを選ぶ場面・選ばない場面
決め手は、そのスキーマがブラウザに配信されるかどうかです。会員登録やアンケートの入力欄のようにクライアントで検証するなら、Zodとの差の約24kB(gzip後)がそのまま初回表示の通信量になるので、Valibotを選びます。設問の多い アンケート管理システムの開発 のように入力画面が多いシステムでも、ライブラリ本体は画面間で共通のファイルにまとめられ、ブラウザのキャッシュも効きます。差が効くのは主に、最初に開く画面の読み込みです。
サーバーの中だけで使う検証なら、Valibotへ移る理由はほとんどありません。OpenAPIの生成ツールやフォームのプラグインなど、Zod専用の周辺ツールに頼っている場合は、それを置き換えるコストのほうが大きいからです。なおtRPCはValibotのスキーマも受け取れるので(tRPCのバリデータの説明)、tRPCを使っていること自体は移らない理由になりません。Zodのエコシステムに残ったままサイズを減らしたいなら、Zod Miniが候補です。ただしZod Miniはメソッドチェーンの代わりに z.string().check(z.minLength(8)) のような関数の形で書くので、既存のスキーマは書き換えが要ります。正常値の検証を極端に速くしたいならArkTypeが候補になりますが、バンドルは最も大きくなります(ArkTypeとZodの違い)。
旧API(v0.30以前)の記事が動かない理由と移行対応表
2024年6月6日のv0.31.0で、検証ルールを配列で渡す書き方が廃止されました。2023〜2024年前半の記事のコードが型エラーになるのはこのためです。設定を疑う前に、まず記事の書き方が次の表の左側になっていないかを見てください。
| 旧(v0.30以前) | 現行(v0.31.0以降・v1) |
|---|---|
string([email()]) |
v.pipe(v.string(), v.email()) |
string([minLength(8)]) |
v.pipe(v.string(), v.minLength(8)) |
number([minValue(0)]) |
v.pipe(v.number(), v.minValue(0)) |
custom(fn, msg) |
v.check(fn, msg) |
toTrimmed() |
v.trim() |
Output<typeof S> |
v.InferOutput<typeof S> |
Input<typeof S> |
v.InferInput<typeof S> |
validate(schema, data) という関数はValibotのどの版にもありません。検証の入口は parse・safeParse・is と、その非同期版だけです。旧 custom() を v1の v.custom に読み替えるのも誤りで、v1の v.custom は旧 special() の改名にあたる「任意の型を検証するスキーマ」です。条件を足したいなら v.check() を使います。
Standard Schema・JSON Schema出力による外部ツール連携
ValibotのスキーマはStandard Schemaという共通の約束に沿った ~standard プロパティを持っています(手元では vendor: 'valibot'・version: 1)。React Hook Form の standardSchemaResolver や TanStack Form のように対応したツールなら、専用のアダプタなしでValibotのスキーマを渡せ、あとからZodやArkTypeへ差し替えることもできます。ただしエラーの path の形はライブラリごとに違うので、差し替えたときはエラー表示の箇所を確かめてください(Standard Schemaの仕組みと実装例)。
OpenAPIの定義やLLMの構造化出力にスキーマを渡すなら、公式の @valibot/to-json-schema(1.8.0)でJSON Schemaに変換できます。
import { toJsonSchema } from '@valibot/to-json-schema';
toJsonSchema(v.object({ email: v.pipe(v.string(), v.email()), age: v.optional(v.number()) }));
// { type: 'object',
// properties: { email: { type: 'string', format: 'email' }, age: { type: 'number' } },
// required: ['email'], $schema: 'http://json-schema.org/draft-07/schema#' }
v1.1からv1.5までの追加機能
| 版(公開日) | 主な追加 |
|---|---|
| 1.1.0(2025-05-06) | message・summarize・parseJson |
| 1.2.0(2025-11-24) | toNumber・toDate・toBoolean・isbn |
| 1.3.0(2026-03-17) | cache・guard・parseBoolean・domain |
| 1.4.0(2026-05-05) | toCamelCase・toSnakeCase・isoDateTimeSecond |
| 1.5.0(2026-09-09) | maxCodePoints など4種・ksuid |
実務で効くのは次の3つです。
v.cache()(1.3):同じ入力の検証結果を使い回す。手元では同じ文字列を2回検証すると、中のv.check()は1回しか呼ばれなかった。入力のたびに走るリアルタイム検証に重い処理が入る場合に効く。v.toCamelCase()(1.4):snake_caseで返るAPIの文字列を、検証と同時にcamelCaseへ変える。'user_name'は'userName'になる。v.parseBoolean()(1.3):環境変数やクエリの'yes'・'0'を真偽値へ変える。
修正で既存の挙動が変わった箇所もあります。1.3で v.creditCard() が13桁のVisaを通すようになり、1.4ではMastercardの桁数を厳しく判定するようになりました。1.5では前の章で触れた v.email() の変更が入っています。版を上げるときは、決済とメールアドレス周りのテストを流してください。各版の詳細は GitHubのReleases にあります。
よくある質問
ValibotとZodの違いは何ですか?
書き方とバンドルサイズが違います。Zodはメソッドチェーン(z.string().min(8))、Valibotは関数をつなぐ書き方(v.pipe(v.string(), v.minLength(8)))です。手元のログインフォームではgzip後にValibot 1.66kB、Zod 26.0kBでした。検証速度は、ログインフォームの正常な入力ではほぼ同じで、入れ子のオブジェクトではZod、不正な入力ではValibotが速い結果でした。
Valibotの最新バージョンは?
2026年10月31日時点の最新は1.5.0(2026年9月9日公開)です。手元の版は npm list valibot で確かめられます。v0.31.0より前の版では、この記事の v.pipe() の書き方は使えません。
optionalとnullableはどう使い分けますか?
v.optional() は undefined(キーが無い状態)を、v.nullable() は null を通します。両方通すなら v.nullish() です。v.optional(v.string(), 'guest') のように2番目の引数を渡すと既定値になります。
Zodのrefineに当たる機能はありますか?
v.check() です。パスワードの一致確認のように複数の項目をまたぐなら、v.partialCheck() と v.forward() を組み合わせ、エラーを確認欄に付けます。
エラーメッセージを日本語にできますか?
できます。各アクションの最後の引数に日本語の文言を渡す方法と、@valibot/i18n/ja を読み込んで v.setGlobalConfig({ lang: 'ja' }) で切り替える方法があります。