Comment on page
Validated を直列に処理したい
cats に含まれる 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 であるからで、公式ドキュメントに詳しくかかれていました。andThen
メソッドを使うことで Validated
な値を直列に処理することができます。val validatedAge = Age.validate(15).toValidatedNec
val validatedChild = validatedAge.andThen(age => Child.validate(age).toValidatedNec)
当然ですが、直列になるため
Age
でバリデーションエラーになった場合には Child
のバリデーションは評価されません。 Age
も Child
も満たさない値 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("もう大人です")
は追加されていないことが確認できますね。一つ前の例は入力が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 がないのでコンパイルエラー
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回使うことでも一応可能ですが、本来並列にできるはずの Age
と Job
のバリデーションも直列になってしまい、 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
1
import Dependencies._
2
3
ThisBuild / scalaVersion := "2.13.10"
4
ThisBuild / version := "0.1.0-SNAPSHOT"
5
ThisBuild / organization := "com.example"
6
ThisBuild / organizationName := "example"
7
8
lazy val root = (project in file("."))
9
.settings(
10
name := "validated_sandbox",
11
libraryDependencies ++= Seq(
12
"org.typelevel" %% "cats-core" % "2.9.0",
13
munit % Test
14
)
15
)
/src/main/scala/example/Main.scala
1
package example
2
3
import cats.data.ValidatedNec
4
import cats.implicits._
5
6
object ValidateTest extends App {
7
/*
8
* 通常パターン
9
*/
10
val validatedAge = Age.validate(2000).toValidatedNec
11
val validatedJob = Job.validate("").toValidatedNec
12
13
val param: ValidatedNec[Error, InputParam] = (validatedAge, validatedJob).mapN(InputParam.apply)
14
15
/*
16
* ケ ース1:Validated の値を使って、別の Validated を作りたい場合
17
*/
18
19
// ダメな例:flatMap がないのでこうは書けない
20
val uncompilableValidatedChild = for {
21
validatedAge <- Age.validate(15).toValidated // flatMap がないのでコンパイルエラー
22
validatedChild <- Child.validate(validatedAge).toValidated
23
} yield validatedChild
24
25
// 解法:andThen を使う
26
val validatedChild = validatedAge.andThen(age => Child.validate(age).toValidatedNec)
27
28
/*
29
* ケース2:複数の Validated から Validated を作りたい
30
*/
31
32
// ダメな例: 入れ子になってしまうが、 flatMap 使えないしどうすれば...
33
val nestedValidatedAdult = // ValidatedNec[Error, ValidatedNec[Error, Adult]]
34
(validatedAge, validatedJob).mapN((age, job) => Adult.validate(age, job).toValidatedNec)
35
val validatedAdult = nestedValidatedAdult.flatetn // コンパイルエラー
36
37
// 解法: 一旦 Either にして flatMap してから Validated にもどす
38
val validatedAdult1 = (validatedAge, validatedJob)
39
.mapN((age, job) => Adult.validate(age, job).toValidatedNec.toEither) // ValidatedNec[Error, Either[NonEmptyChain[Error], Adult]]
40
.withEither(_.flatten)
41
val validatedAdult2 = (validatedAge, validatedJob)
42
.mapN((age, job) => Adult.validate(age, job).toValidatedNec) // ValidatedNec[Error, ValidatedNec[Error, Adult]]
43
.withEither(_.flatMap(_.toEither))
44
45
// よくない解法: 並列に処理できるところも直列にしてしまう
46
val badValidatedAdult = validatedAge
47
.andThen(age => validatedJob
48
.andThen(job => Adult.validate(age, job).toValidatedNec))
49
}
50
51
case class Error(message: String)
52
53
case class Age(age: Int)
54
55
object Age {
56
def validate(age: Int): Either[Error, Age] =
57
Either.cond(age < 1000, Age(age), Error("そんなに生きられません"))
58
}
59
60
case class Child(age: Age)
61
62
object Child {
63
def validate(age: Age): Either[Error, Child] =
64
Either.cond(age.age < 18, Child(age), Error("もう大人です"))
65
}
66
67
case class Job(name: String)
68
69
object Job {
70
def validate(name: String): Either[Error, Job] =
71
Either.cond(name.nonEmpty, Job(name), Error("空文字はダメです"))
72
}
73
74
case class Adult(age: Age, job: Job)
75
76
object Adult {
77
def validate(age: Age, job: Job): Either[Error, Adult] =
78
Either.cond(18 <= age.age, Adult(age, job), Error("まだ子供です"))
79
}
80
81
case class InputParam(age: Age, job: Job)
最終更新 8mo ago