Validated を直列に処理したい

概要

cats に含まれる Validated は、複数の入力値のバリデーションを一つにまとめて返すことができる大変便利な型です。

業務において、入力値のバリデーションを行った値をさらに別の入力値として使いたいというケースがままあり、プロジェクトの新規参入者が悩むことがあるため備忘録として記します。

Validated の値を使って、別の Validated を作りたい

以下のようなコードがあります。

case class Error(message: String)

case class Age(age: Int)
object Age {
  def validate(age: Int): Either[Error, Age] =
    Either.cond(age < 1000, Age(age), Error("そんなに生きられません"))
}

case class Child(age: Age)
object Child {
  def validate(age: Age): Either[Error, Child] = // チェック済みの Age を使いたい
    Either.cond(age.age < 18, Child(age), Error("もう大人です"))
}

バリデーション済みの Age を使って Child のバリデーションを行いたい。

次に記すように flatMap を使って書けないものでしょうか。

import cats.syntax.all._

val uncompilableValidatedChild = for {
  validatedAge <- Age.validate(15).toValidated // flatMap がないのでコンパイルエラー
  validatedChild <- Child.validate(validatedAge).toValidated
} yield validatedChild

しかし Validated には flatMap がないのでコンパイルエラーになってしまいます。

その理由は Validatedは Monad を実装できず Applicative であるからで、公式ドキュメントに詳しくかかれていました。

https://typelevel.org/cats/datatypes/validated.html#of-flatmaps-and-eithers

解法:andThen メソッドを使う

andThen メソッドを使うことで Validated な値を直列に処理することができます。

val validatedAge = Age.validate(15).toValidatedNec
val validatedChild = validatedAge.andThen(age => Child.validate(age).toValidatedNec)

当然ですが、直列になるため Age でバリデーションエラーになった場合には Child のバリデーションは評価されません。 AgeChild も満たさない値 2000 で実行してみましょう。

console
scala> val validatedChild = Age.validate(2000).toValidatedNec.andThen(age => Child.validate(age).toValidatedNec)
val validatedChild: cats.data.Validated[cats.data.NonEmptyChain[example.Error],example.Child] = Invalid(Chain(Error(そんなに生きられません)))

Child のバリデーションエラー Error("もう大人です") は追加されていないことが確認できますね。

複数の Validated の値を使って、別の Validated を作りたい

一つ前の例は入力が1つでしたが、今度は入力が2つ以上のケースです。

case class Age(age: Int)
object Age {
  def validate(age: Int): Either[Error, Age] =
    Either.cond(age < 1000, Age(age), Error("そんなに生きられません"))
}

case class Job(name: String)
object Job {
  def validate(name: String): Either[Error, Job] =
    Either.cond(name.nonEmpty, Job(name), Error("空文字はダメです"))
}

case class Adult(age: Age, job: Job)
object Adult {
  def validate(age: Age, job: Job): Either[Error, Adult] = // 入力が2つ
    Either.cond(18 <= age.age, Adult(age, job), Error("まだ子供です"))
}

普通に書くとネストしてしまいますが、先ほどと同様に flatMap がないためこのように書くことはできません。

val validatedAge = Age.validate(2000).toValidatedNec
val validatedJob = Job.validate("").toValidatedNec

val nestedValidatedAdult: ValidatedNec[Error, ValidatedNec[Error, Adult]] =
  (validatedAge, validatedJob).mapN((age, job) => Adult.validate(age, job).toValidatedNec)
val validatedAdult: ValidatedNec[Error, Adult] = nestedValidatedAdult.flatten // flatMap がないのでコンパイルエラー

解法:一度 Either にする

withEither メソッドを使うと、一旦 Either に変換してから Validated に戻せるため、 flatMap を使ってネストを解消できます。

val validatedAdult = (validatedAge, validatedJob)
  .mapN((age, job) => Adult.validate(age, job).toValidatedNec.toEither)
  .withEither(_.flatten)

または

val validatedAdult = (validatedAge, validatedJob)
  .mapN((age, job) => Adult.validate(age, job).toValidatedNec)
  .withEither(_.flatMap(_.toEither))

実行してみましょう。両方のバリデーションエラーが合成できていることを確認できます。

console
scala> (validatedAge, validatedJob).mapN((age, job) => Adult.validate(age, job).toValidatedNec.toEither).withEither(_.flatten)
val res2: cats.data.Validated[cats.data.NonEmptyChain[example.Error],example.Adult] = Invalid(Chain(Error(そんなに生きられません), Error(空文字はダメです)))

ダメな例

andThen を2回使うことでも一応可能ですが、本来並列にできるはずの AgeJob のバリデーションも直列になってしまい、 Age でバリデーションエラーになると Job が評価されなくなってしまいます。

val validatedAdult = validatedAge
  .andThen(age => validatedJob
    .andThen(job => Adult.validate(age, job).toValidatedNec))

実行すると、次のように Job のバリデーションエラーは表示されず Age のバリデーションエラーだけが表示されてしまいます。

console
scala> validatedAge.andThen(age => validatedJob.andThen(job => Adult.validate(age, job).toValidatedNec))
val res1: cats.data.Validated[cats.data.NonEmptyChain[example.Error],example.Adult] = Invalid(Chain(Error(そんなに生きられません)))

利用したコード

コード
/build.sbt
import Dependencies._

ThisBuild / scalaVersion     := "2.13.10"
ThisBuild / version          := "0.1.0-SNAPSHOT"
ThisBuild / organization     := "com.example"
ThisBuild / organizationName := "example"

lazy val root = (project in file("."))
  .settings(
    name := "validated_sandbox",
    libraryDependencies ++= Seq(
      "org.typelevel"       %% "cats-core"                % "2.9.0",
      munit % Test
    )
  )
/src/main/scala/example/Main.scala
package example

import cats.data.ValidatedNec
import cats.implicits._

object ValidateTest extends App {
  /*
   * 通常パターン
   */
  val validatedAge = Age.validate(2000).toValidatedNec
  val validatedJob = Job.validate("").toValidatedNec

  val param: ValidatedNec[Error, InputParam] = (validatedAge, validatedJob).mapN(InputParam.apply)

  /*
   * ケース1:Validated の値を使って、別の Validated を作りたい場合
   */

  // ダメな例:flatMap がないのでこうは書けない
  val uncompilableValidatedChild = for {
    validatedAge <- Age.validate(15).toValidated // flatMap がないのでコンパイルエラー
    validatedChild <- Child.validate(validatedAge).toValidated
  } yield validatedChild

  // 解法:andThen を使う
  val validatedChild = validatedAge.andThen(age => Child.validate(age).toValidatedNec)

  /*
   * ケース2:複数の Validated から Validated を作りたい
   */

  // ダメな例: 入れ子になってしまうが、 flatMap 使えないしどうすれば...
  val nestedValidatedAdult = // ValidatedNec[Error, ValidatedNec[Error, Adult]]
    (validatedAge, validatedJob).mapN((age, job) => Adult.validate(age, job).toValidatedNec)
  val validatedAdult = nestedValidatedAdult.flatetn // コンパイルエラー

  // 解法: 一旦 Either にして flatMap してから Validated にもどす
  val validatedAdult1 = (validatedAge, validatedJob)
    .mapN((age, job) => Adult.validate(age, job).toValidatedNec.toEither) // ValidatedNec[Error, Either[NonEmptyChain[Error], Adult]]
    .withEither(_.flatten)
  val validatedAdult2 = (validatedAge, validatedJob)
    .mapN((age, job) => Adult.validate(age, job).toValidatedNec) // ValidatedNec[Error, ValidatedNec[Error, Adult]]
    .withEither(_.flatMap(_.toEither))

  // よくない解法: 並列に処理できるところも直列にしてしまう
  val badValidatedAdult = validatedAge
    .andThen(age => validatedJob
      .andThen(job => Adult.validate(age, job).toValidatedNec))
}

case class Error(message: String)

case class Age(age: Int)

object Age {
  def validate(age: Int): Either[Error, Age] =
    Either.cond(age < 1000, Age(age), Error("そんなに生きられません"))
}

case class Child(age: Age)

object Child {
  def validate(age: Age): Either[Error, Child] =
    Either.cond(age.age < 18, Child(age), Error("もう大人です"))
}

case class Job(name: String)

object Job {
  def validate(name: String): Either[Error, Job] =
    Either.cond(name.nonEmpty, Job(name), Error("空文字はダメです"))
}

case class Adult(age: Age, job: Job)

object Adult {
  def validate(age: Age, job: Job): Either[Error, Adult] =
    Either.cond(18 <= age.age, Adult(age, job), Error("まだ子供です"))
}

case class InputParam(age: Age, job: Job)

本サイトの更新情報は、Twitterの株式会社プレセナ・ストラテジック・パートナーズエンジニア公式アカウントで発信しています。ご確認ください。

最終更新