バリデーターを型として表現する — yoshi を作った

June 02, 2026

Scala 3 用のバリデーションライブラリ yoshi を作りました。一般的な Validated[E, A] パターンは「結果値の型」だけど、バリデーターそのものを型として表現し直すと、A → B の型解決・コンテナの自動導出・構造化エラーがぜんぶ型システムに乗ってくる、という話です。

一般的な Validated[E, A] の不満

Validated[E, A] を返す API は Scala のあちこちにあって、ユースケースの大半はこなせます。それでも自分で書きたくなったのは、バリデーターそのものが型として表現されていないことが気になっていたから。

Validated[E, A] はバリデーションの結果値の型です。バリデーター自体は A => Validated[E, B] のような関数として外側で書くことになる。バリデーターが型としてライブラリに渡っていないので、ここから二つの不便が出てきます。

一つ目は、A → B の関係性がライブラリに見えないこと。 バリデーターが型として存在しないので、given による解決や型クラス導出の対象にできない。「String → Int のバリデーターから List[String] → List[Int] のバリデーターを生やす」みたいな話を自動でやれないので、コンテナ系の組み合わせは毎回 traverse で手書きすることになります。

二つ目は、合成が「結果値の組み合わせ」になってしまうこと。 (va, vb, vc).mapN(...) のように、すでに実行した結果を組み合わせる形。バリデーターそのものを >>andThen で型レベルに組み立てて再利用するには、ライブラリ側に別の道具立てが要ります。

要するに、バリデーターが型としてライブラリに渡っていないので、合成も導出も型解決に乗らない。yoshi はこの一点を覆すために、バリデーターそのものを型として置きました。

設計判断 1: バリデーターを型として表現する

中心になる型はこれだけ。

sealed abstract class Validation[+V, -A, +B] {
  def run(a: A): Either[Violations[V], B]
}

Validated[E, A] ではなく Validation[V, A, B]。「バリデーションの結果」ではなく バリデーターそのもの が型として存在します。

A → B の関係性が型システムに乗ることで、ここから先のすべてが型解決に流せるようになります。

// アキュムレート — 両方走らせて違反を全部集める
val both = nonEmpty |+| maxLength(100)

// シーケンシャル — 先に失敗したらそこで止める
val chain = parseInt >> positive

|+| は両方の違反を集めるアキュムレート、>> は最初の失敗で止めるシーケンシャル。これらはバリデーターの型上の合成で、入力と出力の型が合っていれば型システムが許可してくれます。ライブラリ側で「並列合成」「直列合成」を別々の API として抱えこむ必要はない。contramap で入力を変換したり mapError で違反型を変換したり、バリデーターが型として手の中にあるから自然に書けます。

設計判断 2: Parse, don’t validate

バリデーターが型として存在し、A → B の型情報を持っているということは、パースをバリデーションそのものとして表現できるということ。

case class FormInput(name: Option[String], age: String, items: List[FormInput.Item])
case class Order(name: String, age: Int, items: List[Order.Item])

val v: Validation[Violation, FormInput, Order] =
  Validation.cursor[FormInput] { c =>
    (
      c.validateAs[String](_.name),
      c.validateAs[Int](_.age),
      c.validateAs[List[Order.Item]](_.items),
    ).validateN { case (name, age, items) => Order(name, age, items) }
  }

Option[String]String に、StringInt に、List[FormInput.Item]List[Order.Item] に。各フィールドの型変換そのものがバリデーションとして表現されていて、バリデーションを抜けた瞬間に型が強くなる。

Validated[E, A] ベースで同じことをやるなら、各フィールド用のバリデーターを自前で関数として持って、(validateName(input.name), validateAge(input.age), ...).mapN { Order(...) } のように mapN の中で組み立てることになります。その手書き手順がぜんぶ型解決に置き換わる——これが「バリデーターを型として置く」ことの実用上の最大の恩恵だと思っています。

ちなみにコード中の cursor は、アクセサラムダ(_.name など)からパスをコンパイル時に抽出するためのもの。.at("name") を手書きしないで済むようにする仕掛けです。今回の主題ではないので深追いしませんが、「コードがこう書けます」というデモとして置いています。

設計判断 3: コンテナの自動導出

「バリデーターが型として存在する」ことの直接的な見返り。Validation[V, A, B]given にあれば、コンテナ系のインスタンスは型クラス導出で自動派生します。

// given Validation[Violation, String, Int] があれば、これは全部自動
summon[Validation[Violation, Option[String], Option[Int]]]
summon[Validation[Violation, List[String], List[Int]]]
summon[Validation[Violation, Map[String, String], Map[String, Int]]]

Validated[E, A] ベースだと、コンテナの組み合わせごとに traverse 系のヘルパーを都度書くハメになる。「Option の中身をバリデーションして Option で包み直す」「List の各要素をバリデーションして List で集める」みたいなのを毎回。

型システムに乗っていれば、組み合わせ爆発はそもそも発生しません。

設計判断 4: エラーが構造的に出てくる

これは独立した話だけど、実務での体感は大きく変わるところ。

一般的な Validated[NonEmptyList[E], A]E はただのメッセージ型で、「どのフィールドで起きたか」を呼び出し側が手動で組み立てる必要があります。E を自前で「パス付きエラー型」として定義して、ネストするたびにパスを継ぎ足していく——あの作業が毎度発生する。

yoshi の Violations[V]Paths を保持していて、runLeft には「name が必須」「items[1].label が必須」のような構造化されたエラーがそのまま入ってきます。cursor を使えば、ネストしたときにパスは自動で連鎖する。

items[1].label: required
name: required

フォーム入力からドメイン型への変換、という典型ユースケースで、クライアントに「どのフィールドがダメだったか」を返すコストがほぼゼロになる。これがあるかどうかで、実務での使い勝手はかなり変わるんですよね。

「結果値の型」から「バリデーターの型」への置き換え

ここまで挙げた 4 つの判断のうち、構造化エラーを除く 3 つは結局のところ一本の判断から派生しています。

バリデーターを結果値の型ではなく、バリデーター自体の型として宣言する

これだけで、A → B の関係性がライブラリに見えるようになり、パースの道具になり、コンテナの自動導出が効き、合成が型レベルで成立する。yoshi の中身を一行で言うとこういうこと。

ライブラリ紹介としては、衛星的な話として「Scala の型安全性をドメイン境界でどう使うか」とも繋がっています。バリデーターを型として持つということは、バリデーションのロジックを型システムに組み立てさせるということ。型がドメイン境界の関門を作り、型なしの値がドメインに漏れない。「パースとして設計する」というのは要するに、ドメインの中に未検証の型を持ち込ませないということで、型安全性の延長線上の話なんですよね。

まとめ

  • Validated[E, A] は結果値の型で、バリデーター自体は型として表現されない。だから A → B の関係がライブラリに見えず、合成も導出も型解決に乗らない
  • yoshi は Validation[V, A, B] という バリデーター自体の型 を置く。これが起点
  • 型として A → B を持つから、パースとして書ける / コンテナの自動導出が効く / 合成が型レベルで成立する
  • 失敗時の違反は Paths を持つ構造化エラーで、cursor 経由ならパスは自動で連鎖する
  • 「結果値を返す」から「バリデーターを型として宣言する」への置き換えだけで、バリデーション周りの道具立てが型システムに乗り、コードベースからボイラープレートが消える

Profile picture

Shota Hoshino
A functional scala developer