バリデーターを型として表現するライブラリ 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 はこの一点を変えるために、バリデーターそのものを型として定義しました。

バリデーターを型として表現する

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

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 で違反型を変換したりも自然に書けます。

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 に、String は Int に、List[FormInput.Item] は List[Order.Item] になります。各フィールドの型変換そのものがバリデーションなので、バリデーションを通った時点で値はより厳密な型になっている。

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

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

コンテナの自動導出

バリデーターが型として存在することで、直接得られる見返りです。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 系のヘルパーを都度書くことになります。バリデーターを型システムで扱えれば、組み合わせ爆発はそもそも起きません。

構造化エラー

これはここまでの話とは独立していますが、実務での使い心地が大きく変わるところです。

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

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

items[1].label: required
name: required

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

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

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

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

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

これは型安全性の話の延長でもあります。バリデーターを型として持てば、ドメインの境界で値を検査できるので、未検証の値をドメインに入れずに済みます。

まとめ

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

Profile picture

Shota Hoshino
A functional scala developer