タグと型クラスによる合成可能で宣言的なバリデーション

バリデーションの種類と対応する検査済みの値をタグで表し合成可能なバリデーションを作ってみました。
2023.06.06

はじめに

複数の外部の状態に依存するバリデーションを順序付きで実行するコードを見るのにうんざりしたのでなんとかする方法を検討してみました。

よくある実装

以下のような名前(文字列)のバリデーションを行うプログラムを考えます。

  • 名前の長さは1文字以上とする
  • 名前には禁止ワードリストに含まれる文字列は使えない
  • 禁止ワードリストはデータベースから取得する

これをコードにすると以下のようになると思います。

type Name = String
type Error = String

trait GetForbiddenWords[F[_]]:
	def getWords: F[Set[String]]

def validate[F[_]: Monad: GetForbiddenWords](name:Name): EitherT[F, Error, Name] = for {
    _ <- EitherT.fromEither(Either.cond(name.nonEmpty, name, "name should not be empty"))
    _ <- EitherT(summon[GetForbiddenWords[F]].getWords
					.map(ws => Either.cond(ws.contains(name), "forbidden words contains", name))
} yield name

いくつか気に入らない点があります。

  • 複数の観点のバリデーションがvalidateメソッドに押し込まれている
  • validateで行われるバリデーションがメソッドの型だけでわからない
  • バリデーションが増えるに従ってvalidateの実装がどんどん長くなる

改善案

これらをタグ付けと以下の型クラスの導入で改善してみます。

基本的なアイディアは以下の通りです。

  • バリデーション済みの値を検証内容に応じてタグ付けして元の値と区別する
  • バリデーションをタグに対する型クラスとする
  • バリデーションの内容や、バリデーション済みの値を表すためにタグを列挙する

タグ付け

タグ付けにはshapelessのTaggerを使います。以下に定義を引用します。

// from https://github.com/milessabin/shapeless/blob/bad11a2804af22ac47743676e2b259c08ee05ea7/core/shared/src/main/scala/shapeless/typeoperators.scala#L25C1-L35
  object tag:
    def apply[U] = Tagger.asInstanceOf[Tagger[U]]

    trait Tagged[U] extends Any
    type @@[+T, U] = T with Tagged[U]

    class Tagger[U]:
      def apply[T](t: T): T @@ U = t.asInstanceOf[T @@ U]

型クラス ValidationT

以下のように A => Either [E, A@@T]]を取るcase classです。

	// Aに対するバリデーションをあらわす,結果はTでタグ付けされる
  // すなわちバリデーション結果の型はEither[E, A @@ T]
  final case class ValidationT[F[_]: Monad, A, E, T](
      apply: A => EitherT[F, E, A @@ T]
  ):
    def and[U](implicit
        v: ValidationT[F, A, E, T]
    ): ValidationT[F, A, E, T with U] = ValidationT { a =>
      for {
        _ <- apply(a)
        _ <- v.apply(a)
      } yield tag[T with U][A](a)
    }

  end ValidationT

  object ValidationT:
    def lift[F[_]: Monad, A, E, T](
        f: A => F[Either[E, A]]
    ): ValidationT[F, A, E, T] =
      ValidationT(a => EitherT(f(a).map(_.map(tag[T][A](_)))))

  end ValidationT

使い方の例

使い方をみてみます。以下のようなcase classのバリデーションを考えます。

// バリデーション対象
final case class Bird(numWings: Int, likeSeeds: Boolean)

バリデーションの種類はenumで表します。

// バリデーションの種類(=タグ)
  enum BirdValidation:
    case HasWings, LikeSeeds

簡単のため以下のエイリアスを定義しておきます。

type BirdCheckT[F[_], T] = ValidationT[F, Bird, String, T]

LikeSeedsに対するバリデーションを実装します。

given likeSeedsF[F[_]: Monad]: BirdCheckT[F, BirdValidation.LikeSeeds.type] =
    ValidationT.lift(b =>
      Monad[F].pure(
        Either.cond(b.likeSeeds, b, s"you know, No birds dislike seeds.")
      )
    )

次にもう一つバリデーションを実装しますが、1つのフィールドの値だけに関心があるバリデーションを実装するのにそのルートの値全部を受け取ると冗長になりがちです。

Lensを使って興味のあるフィールドの抽出を与え、そのフィールドのバリデーションだけを実装し、元の値へのバリデーションを導出します。

object ValidationT:
    given withFocus[F[_]: Monad, A, B, E, T](using
        lens: Lens[A, B],
        v: ValidationT[F, B, E, T]
    ): ValidationT[F, A, E, T] =
      ValidationT(a => v.apply(lens.get(a)).as(tag[T][A](a)))

これを使ってHasWingsに対するバリデーションを実装します。同じ型のフィールドが複数あって対象が自明でない場合はタグ付けを使うと良いかと思います。

trait GetProperWingCount[F[_]]:
    def getCount: F[Int]
end GetProperWingCount

  // バリデーションの実装
  given hasWingsF[F[_]: Monad: GetProperWingCount]
      : ValidationT[F, Int, String, BirdValidation.HasWings.type] =
    ValidationT.lift { numWings =>
      summon[GetProperWingCount[F]].getCount.map(c =>
        Either.cond(
          numWings === c,
          numWings,
          s"Inproper number of wings ${numWings.toString}"
        )
      )
    }
//Lensの定義
given wingLens: Lens[Bird, Int] = GenLens[Bird](_.numWings)

最後に定義したバリデーションを組み合わせます。必要なバリデーションに対応するタグをwith で組み合わせ、バリデーションのインスタンスをand で合成します。

  // 必要なバリデーションを列挙
  type ValidBird = BirdValidation.HasWings.type
    with BirdValidation.LikeSeeds.type

  // 必要なバリエーションに一致するように合成する
  given allValidations[F[_]: Monad: GetProperWingCount]
      : BirdCheckT[F, ValidBird] =
    summon[BirdCheckT[F, BirdValidation.HasWings.type]]
      .and[BirdValidation.LikeSeeds.type]

FにOptionを与えて実行してみます。

val res = allValidations[Option].apply(usecase.Bird(2, true))
pprint.pprintln(res)
//EitherT(value = Some(value = Right(value = Bird(numWings = 2, likeSeeds = true))))

またバリデーション済みの値をBird @@ ValidBirdを使って表すこともできます。

まとめ

やや無理矢理な実装ではありますが、以下を実現できました。

  • バリデーションの種類と対応する検査済みの値をタグで表せる
  • バリデーションの必要な箇所と実装を分離できる