Valibotのv.pipeでLLMのモデル名を許可リストで検証する際、v.checkとv.picklistのどちらを選ぶかでエラーの形と型の効き方が両方どう変わるか分かる。
// A: v.check — 述語を渡す (メッセージが素っ気ない)
v.pipe(v.string(), v.check((m) => ALLOWED.includes(m), "許可されていないモデル"))
// B: v.picklist — スキーマで型まで絞る
v.picklist(["gpt-5.6", "luna"])
// C: 自作アクション — 組み込みと同じ形のエラーを出す
v.pipe(v.string(), allowedModel(["gpt-5.6", "luna"]))
Cの allowedModel を書くには、Valibotのアクションが内部的にどんなオブジェクトで、どんな約束で動いているかを知る必要がある。本稿はそれをvalibot 1.4.2のソースと実行結果で読む。核はシンプルで、検証の本体は requirement (判定材料) と ~run (手続き) の2つに閉じ込められている。この分離を一度見ると、組み込みアクションのソースが全部同じ型の文章に読えるようになる。
本稿は ~run プロトコルを読むシリーズの4本目で、テーマは「スキーマだけじゃなく、検証アクションも同じ仕組みで動く」。
- strictObjectをWebhook検証に使うと実payloadの余分キーで全滅する — v.objectへの修正
- v.objectは未知キーをどこで捨てるのか — ~runプロトコルとDatasetで内部を読む
- v.parseとv.safeParseはどこで分岐するのか — たった1つのif文とDatasetの実測
実行環境
- valibot 1.4.2
- 検証はNode.js 24での実行結果による (以下、引用コードは断りがなければ1.4.2の実物)
組み込みアクションの骨格 — minLengthを読む
まず v.minLength(3) が返すものの実物。ファクトリが返すのはクラスのインスタンスではなく、フィールドが7つ並んだプレーンオブジェクトだ。
function minLength(requirement, message) {
return {
kind: "validation",
type: "min_length",
reference: minLength,
async: false,
expects: `>=${requirement}`,
requirement,
message,
"~run"(dataset, config) {
if (dataset.typed && dataset.value.length < this.requirement) {
_addIssue(this, "length", dataset, config, {
received: `${dataset.value.length}`,
});
}
return dataset;
},
};
}
フィールドを役割で分けると3組になる。
| フィールド | 役割 |
|---|---|
kind / type
|
パイプライン上の段 (validation) と、issueに載る名前 (min_length) |
expects / message
|
エラー文の組み立て素材 (期待値の文字列 / 上書き用メッセージ) |
requirement / ~run
|
判定材料と手続き — 検証の本体 |
~run の中身は3行で、これがすべてのアクションで同じ構文になる。
-
dataset.typedを確認する (前段のスキーマが入力を型として受け入れた場合だけ検証する) - 条件に違反していたら
_addIssue(this, …)で報告する (issueを自分で組み立てない) -
datasetをそのまま返す (結果はミューテーションで運ばれる)
v.check はこの形の最小版で、requirement が (input) => boolean の関数に置き換わっている。
function check(requirement, message) {
return {
kind: "validation",
type: "check",
/* …expects: null… */
requirement,
message,
"~run"(dataset, config) {
if (dataset.typed && !this.requirement(dataset.value)) {
_addIssue(this, "input", dataset, config);
}
return dataset;
},
};
}
minLength の requirement は数値、check の requirement は関数。判定に必要なデータなら何でも入るのがこのフィールドで、~run はそれを消費する手続きにすぎない。ここが本稿の主張する「閉じ込め」だ。
自作アクション — allowedModelを書く
骨格が分かったので、冒頭のCを書く。許可リストを requirement に持ち、~run で判定する。
import * as v from "valibot";
function allowedModel(list: string[], message?: string) {
return {
kind: "validation",
type: "llm_model",
reference: allowedModel,
async: false,
expects: `(${list.map((m) => JSON.stringify(m)).join(" | ")})`,
requirement: list,
message,
"~run"(dataset: any, config: any) {
if (dataset.typed && !this.requirement.includes(dataset.value)) {
v._addIssue(this, "model", dataset, config, {
received: JSON.stringify(dataset.value),
});
}
return dataset;
},
} as const;
}
const ModelSchema = v.pipe(v.string(), allowedModel(["gpt-5.6", "luna"]));
v._addIssue はアンダースコア付きだがパッケージからexportされている実装で、issueの組み立てを一括して引き受ける。実行すると、組み込みと区別がつかないエラーが出る。
// v.safeParse(ModelSchema, "sonnet") の issues[0]
{
"kind": "validation",
"type": "llm_model", // ← 自分のtype名
"input": "sonnet",
"expected": "(\"gpt-5.6\" | \"luna\")", // ← expectsから組み立て
"received": "\"sonnet\"",
"message": "Invalid model: Expected (\"gpt-5.6\" | \"luna\") but received \"sonnet\"",
"requirement": ["gpt-5.6", "luna"] // ← 許可リストがそのまま同乗
}
注目すべきは最後のフィールドだ。_addIssue はaction自身から kind / type / expects / requirement を読んでissueを組み立てるので、判定材料がエラーにそのまま乗る。ログ収集やエラー表示側は、issue.requirement から「候補は何だったのか」を失わずに取り出せる。これが「素っ気ない v.check メッセージ」との違いで、requirement をデータとして持たせたことの直接の効能になる。
TypeScriptで書く場合は、戻り値に型を付ければ組み込みと同じ補完が効く。公式の型定義ではアクションは BaseValidation<TInput, TOutput, TIssue> を継承したinterface (例: CheckAction) として宣言されているので、自作もこれに倣う。
interface AllowedModelAction
extends v.BaseValidation<string, string, AllowedModelIssue> {
readonly type: "llm_model";
readonly expects: string;
readonly requirement: string[];
}
なぜ v.picklist ではダメな場面があるのか
Bの v.picklist でもモデル名は検証できる。違うのはパイプラインのどの段で動くかと型への効き方だ。実測で比べる。
// v.picklist(["gpt-5.6","luna"]) に "sonnet" →
{ "kind": "schema", "type": "picklist", "expected": "(\"gpt-5.6\" | \"luna\")", … }
// v.pipe(v.string(), allowedModel([...])) に "sonnet" →
{ "kind": "validation", "type": "llm_model", "requirement": […], … }
-
picklistはスキーマ (kind:schema)。型引数から("gpt-5.6"|"luna")に推論され、以降のコードでリテラル型が効く。代わりに「文字列であること」と「許可リストであること」が1つのスキーマに固定される - 自作アクションは検証 (kind:
validation)。stringのまま、既存のパイプに検証だけを足す。前段のv.minLengthとの直列も素直で、失敗は両方のissueが1つのresultに積まれる (後述)
使い分けの目安: 型を絞りたいならスキーマ、既存のパイプに検証を足したいならアクション。モデル名のように「設定ファイル由来でリストが実行時に決まる」ものは、リテラル型の恩恵が最初から無いのでアクション側に分がある。
typedガードは省けない — アクションが走らないケース
~run の1行目 dataset.typed は飾りではない。前段のスキーマが入力を型として受け入れていなければ、検証は実行すらされない。数値を期待するパイプに文字列を流した実測:
v.safeParse(v.pipe(v.number(), allowedModel(["gpt-5.6"])), "luna")
// issues: [ { kind: "schema", type: "number", … } ] ← llm_model は不在
"luna" は v.number() を通らないので typed が偽のまま流れ込み、allowedModel の ~run は if の1行目で抜ける。この約束があるから、アクション側は dataset.value がスキーマの型を持っている前提で書ける (上の例なら string のメソッドを呼んでよい)。ガードを書き忘れた自作アクションは、dataset.value が undefined や別型のときに死ぬ。
複数のissueを積みたい — v.rawCheck
「検証1つにつきアクション1つ」で足りないとき、最後の道具が v.rawCheck だ。これは「issueの積み方だけ借りて、判定は全部自分で書く」形で、addIssue 関数を受け取る。
const RawModel = v.pipe(
v.string(),
v.rawCheck(({ dataset, addIssue }) => {
if (!dataset.typed) return;
if (!/^[a-z0-9.-]+$/.test(dataset.value)) {
addIssue({ label: "model", expected: "小文字英数字" });
}
if (dataset.value.length > 20) {
addIssue({ label: "length", expected: "<=20" });
}
}),
);
v.safeParse(RawModel, "INVALID!!LONG!!MODEL!!NAME").issues
// [
// { type: "raw_check", message: "Invalid model: Expected 小文字英数字 but received …" },
// { type: "raw_check", message: "Invalid length: Expected <=20 but received …" },
// ]
1つの ~run から複数のissueを積めるのがrawCheckで、label / expected / received / path を自分で制御したいときに使う。自作アクション (型名が付く・requirementが乗る) とrawCheck (積み方が自由・型名は raw_check 固定) のどちらを取るかは、「エラーを後から集計するか」で決まる。ログで type 別に集計する予定があるなら、名前を持つ自作アクションにしておく方が後で効く。
直列の挙動 — 前段が失敗しても後段は走る
デフォルト設定では、パイプ内のアクションは前段の失敗で止まらない。v.minLength(3) と allowedModel を直列にした実測:
v.safeParse(v.pipe(v.string(), v.minLength(3), allowedModel(["gpt-5.6"])), "ab")
// issues: [ validation/min_length, validation/llm_model ] ← 両方
"ab" は3文字未満でモデル名でもないので、両方のissueが1つのresultに並ぶ。abortEarly的な挙動にしたければ v.safeParse のconfig側 (abortPipeEarly) で制御する。アクション単体には設定が無く、この「resultへの集約」がパイプの契約なので、自作アクション側で早期リターンを発明しなくてよい。
まとめ
- Valibotの検証アクションは、
kind/type/reference/async/expects/requirement/message/~runを持つプレーンオブジェクト。クラスでも高階関数でもない - 本体は
requirement(判定材料 — データでも関数でもよい) と~run(typedガード → 判定 →_addIssue→ return) の2つに閉じ込められている -
_addIssueがaction自身からissueを組み立てるため、requirementがエラーに同乗する。許可リストのような判定材料を、エラー表示側は失わずに読める -
picklist(スキーマ・型が絞れる) と自作アクション (検証・既存パイプに足せる) の使い分けは、型を絞りたいかどうかで決まる -
dataset.typedガードは省けない。型が確定していない入力ではアクションは走らない前提が、~runの書き方を支えている - 複数issueやpathの自由な積み方は
v.rawCheck。type名を持たせたいなら自作アクション
導線
- v.pipe / v.check / v.rawCheck — Valibot API
- check.ts / minLength.ts / rawCheck.ts / _addIssue.ts (GitHub, タグ v1.4.2)
- v.parseとv.safeParseはどこで分岐するのか (前回・Qiita)
- LLMのツール呼び出しを型で閉じ込める: Valibot許可リスト検証とstrictObjectの罠 (Zenn)
自作の検証を足せるようになったら、次は「どのLLMに検証を書かせるか」の選定も同じ実測で決められる。LLMはベンチマークで選ばない — 自業務の実タスクで60分比較する手順に記録テンプレートをまとめている。
本稿の実装と実行結果は、valibot 1.4.2 (Node.js 24) 時点の実測による。