0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

Valibotの自作validation actionをrequirementと~runに閉じ込めて検証を局所化する

0
Last updated at Posted at 2026-09-05

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本目で、テーマは「スキーマだけじゃなく、検証アクションも同じ仕組みで動く」。

実行環境

  • 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行で、これがすべてのアクションで同じ構文になる。

  1. dataset.typed を確認する (前段のスキーマが入力を型として受け入れた場合だけ検証する)
  2. 条件に違反していたら _addIssue(this, …) で報告する (issueを自分で組み立てない)
  3. 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 名を持たせたいなら自作アクション

導線

自作の検証を足せるようになったら、次は「どのLLMに検証を書かせるか」の選定も同じ実測で決められる。LLMはベンチマークで選ばない — 自業務の実タスクで60分比較する手順に記録テンプレートをまとめている。
本稿の実装と実行結果は、valibot 1.4.2 (Node.js 24) 時点の実測による。

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?