概要
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
のバリデーションは評価されません。 Age
も Child
も満たさない値 2000
で実行してみましょう。
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))
実行してみましょう。両方のバリデーションエラーが合成できていることを確認できます。
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回使うことでも一応可能ですが、本来並列にできるはずの Age
と Job
のバリデーションも直列になってしまい、 Age
でバリデーションエラーになると Job
が評価されなくなってしまいます。
val validatedAdult = validatedAge
.andThen(age => validatedJob
.andThen(job => Adult.validate(age, job).toValidatedNec))
実行すると、次のように Job
のバリデーションエラーは表示されず Age
のバリデーションエラーだけが表示されてしまいます。
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(そんなに生きられません)))
利用したコード
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 ) )
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の株式会社プレセナ・ストラテジック・パートナーズエンジニア公式アカウントで発信しています。ご確認ください。