1
1

More than 1 year has passed since last update.

Cats のデータ型覚え書き(Validated ~ Either との使い分けなど ~)

Last updated at Posted at 2022-02-27

はじめに

前回は Cats のデータ型覚書(NonEmptyList)という記事を投稿しました。

今回は Cats に定義されている Validated というエラーを累積するタイプのデータ型と、標準のエラーに関するデータ型である Either との比較を交えつつ見ていきます。

まず cats の公式ページを読む

typelevel の cats の公式ページの Validated に関するページは以下になります。

めちゃくちゃざっくり要約すると

  • Either との違い・類似点・相互変換について
  • Applicative だけど FlatMap ではないという話
  • andThen, withEither 関数の紹介

が書かれていました。

Either, Validatedの使い分け

これらの似たようなデータ型はどのように使い分けるのが良いでしょうか。

まずは、それぞれの性質について考えてみましょう。

Either の性質

  • fail fast なエラーハンドリングを行うことができる
    • 複数の Validation を行う場合は最初に失敗したエラー内容が返る

Validated の性質

  • 複数のエラーを累積できる
  • モナドじゃない(Applicative ではある)
    • なので、flatMap とか for 式で処理をまとめ上げるような書き方はできない
  • お互いに依存のない(A が失敗したら B も当然失敗する、みたいな依存がない)項目をまとめ上げるような用途に合う。
    • 画面の複数の form 値を同時にエラー出したりなどがユースケース
  • 累積するタイプなので、left 側は Semigroup を要求する関数も結構あって、left 側を NonEmptyList, NonEmptyChain として使うためのヘルパー関数もたくさん用意されれいる。

Either と Validated の使い分け

Either は fail fast なモナド、つまり for 式や flatMap を使った場合、最初に失敗したエラーを、短絡的に返すようなデータ型です。

例として、正の整数であるか確かめるような Validation を作ってみましょう。

def validate(string: String): Either[Throwable, Int] =
  for {
    i <- string.toIntOption.toRight(new Error("Int じゃない"))
    _ <- Either.cond(0 < i, (), new Error("正の整数じゃない"))
  } yield i

println(validate("a"))
// Left(java.lang.Error: Int じゃない)
println(validate("-1"))
// Left(java.lang.Error: 正の整数じゃない)
println(validate("1"))
// Right(1)

このように、最初に失敗した理由が出力されていることがわかると思います。この性質は、

  • 検証内容に依存がある場合
  • 依存がない場合でもパフォーマンスとして最初の検証エラーでリターンしたい場合

に役に立つものかなと思います。

今回でいうと、Int かどうかのチェックに失敗した場合、それが正の整数かどうかというのはチェックするのが無意味であるという意味で、検証内容に依存があるといえると思います。

チェック項目がお互いに依存していない場合

依存がない場合の例として、たとえば、

  • 3文字以上かどうか
  • 数値であるか

ということをチェックする Validation を作ってみましょう。

def validate(string: String): Either[Throwable, Int] =
  for {
    _ <- Either.cond(3 <= string.length, (), new Error("3文字以上じゃない"))
    i <- string.toIntOption.toRight(new Error("Int じゃない"))
  } yield i

println(validate("a"))
// Left(java.lang.Error: 3文字以上じゃない)
println(validate("-12"))
// Right(-12)

このとき、結果としてほしいのは3文字以上の数値ではあると思うのですが、これらの2つのチェック項目自体には依存関係はないですよね。

例えば、"a"入力した人からすると、

  • 3文字以上じゃないし、数値でもない

というように複数のエラー内容が返ってきたほうが、validation をパスする入力値を作りやすいと思います。(今回の実装だと、3文字以上であればなんでもいいと思って、"abc"と入力したら今度は数値じゃないと怒られて辛い思いをしますよね?)

今回のような実装を、パフォーマンスの観点からあえて選択する場合は良いのですが、画面の入力値のように、できるだけ多くのエラーを返してくれたほうがありがたい場合は、どのような実装が考えられるでしょうか?

Either での実装は考えただけでめんどくさそうですが、、、

def validate(string: String): Either[Throwable, Int] = {

  val a = Either.cond(3 <= string.length, (), new Error("3文字以上じゃない"))

  val b = string.toIntOption.toRight(new Error("Int じゃない"))

  (a, b) match {
    case (Right(_), Right(i)) => i.asRight
    case (Right(_), Left(error)) => error.asLeft
    case (Left(error), Right(_)) => error.asLeft
    case (Left(error1), Left(error2)) => new Error(s"${error1.getMessage}し、${error2.getMessage}").asLeft
  }
}
println(validate("a"))
// Left(java.lang.Error: 3文字以上じゃないし、Int じゃない)
println(validate("99"))
// Left(java.lang.Error: 3文字以上じゃない)
println(validate("abc"))
// Left(java.lang.Error: Int じゃない)
println(validate("-12"))
// Right(-12)

めちゃくちゃめんどくさいですね。そういうときに使うのが Validated というわけです。

def validate(string: String): ValidatedNel[Throwable, Int] = {

  val a = Validated.condNel(3 <= string.length, (), new Error("3文字以上じゃない"))

  val b = string.toIntOption.toValidNel(new Error("Int じゃない"))

  a *> b
}

println(validate("a"))
// Invalid(NonEmptyList(java.lang.Error: 3文字以上じゃない, java.lang.Error: Int じゃない))
println(validate("99"))
// Invalid(NonEmptyList(java.lang.Error: 3文字以上じゃない))
println(validate("abc"))
// Invalid(NonEmptyList(java.lang.Error: Int じゃない))
println(validate("-12"))
// Valid(-12)

型とか表現が違うものになりましたが、伝えたい情報は変わっていないのがわかると思います。

伝えたい情報は変わっていないのに、あんなにやばかったパターンマッチがa *> bだけで済むようになったのは、便利以外の何者でも有りません。便利ですね。

EitherNel を使う

蛇足ですが、cats を使っている前提であれば Either を使う場合でも EitherNel(Either[NonEmptyList[E], A]のタイプエイリアス)が用意されていて、それを使うことで前述したパターンマッチの地獄みたいなコードは書かなくても済みます。

val a = Either.cond(3 <= string.length, (), new Error("3文字以上じゃない")).toEitherNel
val b = string.toIntOption.toRightNel(new Error("Int じゃない"))
println(List(a, b).parSequence)
// Left(NonEmptyList(java.lang.Error: 3文字以上じゃない, java.lang.Error: Int じゃない))

チェック項目がお互いに依存している場合

さて、「Either と Validated の使い分け」の冒頭で、引数で受け取った文字列が正の整数であるかどうかをチェックする関数を Either を使って実装しました。

def validate(string: String): Either[Throwable, Int] =
  for {
    i <- string.toIntOption.toRight(new Error("Int じゃない"))
    _ <- Either.cond(0 < i, (), new Error("正の整数じゃない"))
  } yield i

println(validate("a"))
// Left(java.lang.Error: Int じゃない)
println(validate("-1"))
// Left(java.lang.Error: 正の整数じゃない)
println(validate("1"))
// Right(1)

これを Validated を使って同じ様な(fail fastな)表現ができないかみてみましょう。

def validate(string: String): Validated[Error, Int] =
  string.toIntOption.toValid(new Error("Int じゃない")).andThen { i =>
    Validated.cond(0 < i, i, new Error("正の整数じゃない"))
  }

println(validate("a"))
// Invalid(java.lang.Error: Int じゃない)
println(validate("-1"))
// Invalid(java.lang.Error: 正の整数じゃない)
println(validate("1"))
// Valid(1)

Validated は flatMap を持っていないので for 式を使うことはできないのですが、andThen という関数を使うと Either::flatMap と同じように fail fast なエラー処理として実装することができました。

同じような表現はできたものの、ネストが深くなった場合に、辛いことになるのは目に見えていますね。。ですので、fail fast な処理は餅は餅屋的な感じ Either で書いておけばいいんじゃないかなと思うのですが、andThenという語感が「こいつらは関連しているんだぞ〰」という雰囲気をビンビンに感じさせる関数名だと思うので、もともと Validated を使っているコンテキストで、シンプルな fail fast な処理を混ぜたくなった場合とかは、こちらを使うのはありなんじゃないかなと個人的には思いました。

チェック対象が複数存在し、依存のあるチェック項目とないものが交じる場合

最後は、Either と Validated を両方使いたいケースを考えてみましょう。

Validated の中に fail fast なエラーが含まれていた場合

その中でも、まずはチェック内容に依存のない項目同士が存在するものの、その項目に対するチェック自体には依存がある場合についてです。

よくわからないと思うので具体例で示しますが、

アカウント登録などで、名前と年齢を登録する処理に対して、

  • 名前に ! は含まれてはいけない。
  • 名前に taguchi が含まれていなくてはいけない。
  • 年齢は整数値である必要がある。
  • 年齢は 20 以上でなくてはいけない。

という「成人済み田口専用 SNS」みたいな要件があるとき、

  • 名前と年齢については、それぞれのお互いのチェック項目が依存していない
  • 年齢の中のチェックは、依存関係がある。(整数値でなければ、20 かどうかチェックできないという依存)
  • 名前の中のチェックは、依存関係がない。(! が含まれていて、taguchi が含まれない場合はどちらのエラーも同時に出力可能である。)

というような状態になっていると思います。

case class Parson(name: Parson.Name, age: Parson.Age)

object Parson {

  case class Name(override val toString: String) {
    require(!toString.contains("!"))
  }

  case class Age(toInt: Int) {
    require(20 <= toInt)
  }
}

このように、チェックが終わったあとの型を Parson というものにするとして、バリデーション関数は、name, age という文字列を受け取ることにしましょう。まずは、普通に Either を返すようにしてみるとインターフェイスは、以下のようになります。

def validate(name: String, age: String): Either[Throwable, Parson]

こちらの関数を実装してみましょう。

def validate(name: String, age: String): Either[Throwable, Parson] =
  for {
    parsonName <- validateName(name)
    parsonAge <- validateAge(age)
  } yield Parson(parsonName, parsonAge)

private def validateName(name: String): Either[Throwable, Parson.Name] =
  for {
    _ <- Either.cond(!name.contains("!"), (), new Error("名前に ! は含まれてはいけない。"))
    parsonName <- Either.cond(name.contains("taguchi"), Parson.Name(name), new Error("名前に taguchi が含まれていなくてはいけない。"))
  } yield parsonName

private def validateAge(age: String): Either[Throwable, Parson.Age] =
  for {
    i <- age.toIntOption.toRight(new Error("年齢は整数値である必要がある。"))
    parsonAge <- Either.cond(20 <= i, Parson.Age(i), new Error("年齢は 20 以上でなくてはいけない。"))
  } yield parsonAge

こんな感じになるでしょうか。

実行結果は以下のようになります。

println(validate("nozomi!", "abc"))
// Left(java.lang.Error: 名前に ! は含まれてはいけない。)
println(validate("nozomi", "abc"))
// Left(java.lang.Error: 名前に田口が含まれていなくてはいけない。)
println(validate("nozomi taguchi", "abc"))
// Left(java.lang.Error: 年齢は整数値である必要がある。)
println(validate("nozomi taguchi", "19"))
// Left(java.lang.Error: 年齢は 20 以上でなくてはいけない。)
println(validate("nozomi taguchi", "36"))
// Right(Parson(nozomi taguchi,Age(36)))

この実装では Either を使っているので、最初に引っかかったチェックのエラー内容が出力されているのがわかると思いますが、

  • 名前と年齢にはお互いのチェックに対して依存関係はないので、同時に出力できるはず
  • 名前のチェックは同時に出せるはず。(nozomi! は、! 付いてるし、taguchi がないしで、複数のエラーをはらんでいるはず)

ということが大体の場合やりたいことだと思います。

前述したように、パフォーマンスの観点から「ともかく早く返したいので、最初にエラーが起きた時点で値を返したい」という要望でない限りは、依存がないエラーは複数出してあげたいですよね?

なので、今回は Validated として返してあげるように修正しましょう。

def validate(name: String, age: String): ValidatedNel[Throwable, Parson] =
  validateName(name).map2(validateAge(age).toValidatedNel)(Parson(_, _))

private def validateName(name: String): ValidatedNel[Throwable, Parson.Name] =
  Validated.condNel(!name.contains("!"), (), new Error("名前に ! は含まれてはいけない。")) *>
    Validated.condNel(name.contains("taguchi"), Parson.Name(name), new Error("名前に taguchi が含まれていなくてはいけない。"))

private def validateAge(age: String): Either[Throwable, Parson.Age] = {
  for {
    i <- age.toIntOption.toRight(new Error("年齢は整数値である必要がある。"))
    parsonAge <- Either.cond(20 <= i, Parson.Age(i), new Error("年齢は 20 以上でなくてはいけない。"))
  } yield parsonAge
}

このような形になりました。

println(validate("nozomi!", "abc"))
// Invalid(NonEmptyList(java.lang.Error: 名前に ! は含まれてはいけない。, java.lang.Error: 名前に taguchi が含まれていなくてはいけない。, java.lang.Error: 年齢は整数値である必要がある。))
println(validate("nozomi", "abc"))
// Invalid(NonEmptyList(java.lang.Error: 名前に taguchi が含まれていなくてはいけない。, java.lang.Error: 年齢は整数値である必要がある。))
println(validate("nozomi taguchi", "abc"))
// Invalid(NonEmptyList(java.lang.Error: 年齢は整数値である必要がある。))
println(validate("nozomi taguchi", "19"))
// Invalid(NonEmptyList(java.lang.Error: 年齢は 20 以上でなくてはいけない。))
println(validate("nozomi taguchi", "36"))
// Valid(Parson(nozomi taguchi,Age(36)))

最初の入力の時点で、3つのエラー内容が含まれているため、入力者としては試行錯誤の回数が減って良いですよね。

今回、年齢チェックの2項目は、依存があるので Either のままで使うときに toValidatedNel をしていますが、
andThen を使った場合はどういうふうになるかと言うと、

private def validateAge(age: String): ValidatedNel[Throwable, Parson.Age] =
  age.toIntOption.toValidNel(new Error("年齢は整数値である必要がある。")).andThen { i =>
    Validated.cond(20 <= i, Parson.Age(i), new Error("年齢は 20 以上でなくてはいけない。")).toValidatedNel
  }

このような形で、Option::toValidNel, Validated::cond + Validated::toValidatedNel を使って実装することもできます。

fail fast な処理の中に Validated なエラーがある場合

続いては、Either の中に、Validated なエラーがあるようなケースについて考えます。

これまでやってきたチェックには、上位の存在がいて、そこでは、

  • パラメータには必ず名前が存在する
  • パラメータには必ず年齢が存在する
  • どちらも存在するとき、検証を行う。

ということをやる実装が必要だったときのことをかんがえてみましょう。

つまり、イメージとしては

def validate(parameters: Map[String, String]) = 
  for {
    name <- parameters.get("name").toRight(new Error("リクエストに name が含まれていません。"))
    age <- parameters.get("age").toRight(new Error("リクエストに age が含まれていません。"))
    parson <- validate(name, age).toEither
  } yield parson

こういうことがやりたい場合についてです。

ここで問題になるのが、Either の左側の型をどうするのかという問題があると思います。
通常の Either だと、左側の型は、複数になることがないので Throwable などで良いと思うのですが、ValidatedNel を toEither した場合は、左側がNonEmptyList[Throwable]になってしまいます。
これをどう解決するかと言うと、

  • EitherNel を返すようにする
  • NonEmptyList[Throwable]をまとめた Throwable なクラスを作る

の二種類の方法が考えられると思います。

EitherNel を返すようにする。

Cats には、ValidatedNel などと同じように EitherNel というタイプエイリアスが定義されており、他のクラスからのコンバートもできるようになっています。
今回でいうと、Option::toRight の代わりに、 Option::toRightNel を使い、ValidatedNel に toEither を使うと、EitherNel を返すことができます。

def validate(parameters: Map[String, String]): EitherNel[Throwable, Parson] =
  for {
    name <- parameters.get("name").toRightNel(new Error("リクエストに name が含まれていません。"))
    age <- parameters.get("age").toRightNel(new Error("リクエストに age が含まれていません。"))
    parson <- validate(name, age).toEither
  } yield parson

このように、Either の左側を NonEmptyList にすることによって、Either の中で ValidatedNel が出てきた場合も対処することができます。

NonEmptyList[Throwable]をまとめた Throwable なクラスを作る

次の考え方は、

def validate(parameters: Map[String, String]): Either[Throwable, Parson]

関数の返り値としてはEither[Throwable, Parson]を保ちたい、という側面からのアプローチになります。

これを返り値として返す場合、どういう実装が考えられるかと言うと、

case class Errors(toNel: NonEmptyList[Throwable]) extends Throwable

こんな感じで、NonEmptyList な値を複数もつ Throwable を継承したクラスを作って Validated の返り値を leftMap してあげるようにすれば実現できます。

def validate(parameters: Map[String, String]): Either[Throwable, Parson] =
  for {
    name <- parameters.get("name").toRight(new Error("リクエストに name が含まれていません。"))
    age <- parameters.get("age").toRight(new Error("リクエストに age が含まれていません。"))
    parson <- validate(name, age).toEither.leftMap(Errors)
  } yield parson

どちらの方法を選ぶかは、エラーハンドリングのしやすい方を選べば良いと思いますが、どっちでもいい場合は EitherNel を使っておいたほうが多少柔軟な気は個人的にしました。

Validated はどんな型クラスのインスタンスか

Validated のインスタンス定義をみていると、面白い事情を汲み取ることができます。

implicit def catsDataSemigroupKForValidated[A](implicit A: Semigroup[A]): SemigroupK[Validated[A, *]]

左側(エラーを表すクラス)が Semigroup のインスタンスである場合に限り、Validated が SemigroupK になるように定義されていることがわかります。

implicit def catsDataAlignForValidated[E: Semigroup]: Align[Validated[E, *]]

コードの表現はなぜか揃っていないのですが、Align も同じように エラー側が Semigroup の場合に限り、Validated が Align のインスタンスになるように定義されています。

implicit def catsDataMonoidForValidated[A, B](implicit A: Semigroup[A], B: Monoid[B]): Monoid[Validated[A, B]]

こちらも似ていますが、エラー側が Semigroup でかつ、ライト側が Monoid である場合に限り、 Validated が Monoid のインスタンスになるように定義されています。

implicit def catsDataApplicativeErrorForValidated[E](implicit E: Semigroup[E]): ApplicativeError[Validated[E, *], E]

ほかと同様に、エラー側が Semigroup の場合に限り Validated が ApplicativeError のインスタンスになるように定義されています。

まとめると、Validated のエラー側が Semigroup になっている場合、Validated として以下のインスタンスになることができます。

  • Semigroup(Monoid にするには更にライト側が Monoid である必要がある)
  • Align
  • ApplicativeError

Validated を使う目的は、エラーの蓄積だと思いますので、エラー側の型はリスト的ななにかを使うことを想定しているという感じでしょうか。Validated が持っている関数を眺めると、NonEmptyList, NonEmptyChain を使うと便利にこのデータ型を利用することができそうです。

Validated の関数覚書

最後に Validated の持っている関数を眺めて、便利に使えるようにしておきたいと思います。
ファクトリメソッドと、個人的に面白いと感じた関数をいくつか紹介します。
基本的には、Either と同じような関数が多いのですが、エラーを累積するという観点などが違いとして現れている部分が面白いかと思います。

Validated のファクトリメソッド

まずはファクトリメソッドです。

  • invalid
  • valid
  • cond
  • catchNonFatal

Either でいう、left, right が invalid, valid なので、それを pure な感じで生成する場合のメソッドが用意されているのと、 Either::cond と同じように(Option::when と似たように)、boolean によってインスタンスを作る手段が用意されています。

また、cats で定義されている Either::catchNonFatal の Validated ver も用意されています。

  • invalidNel
  • validNel
  • condNel
  • validNec
  • invalidNec
  • condNec

それぞれの NonEmptyList, NonEmptyChain バージョンが定義されているのも面白いですね。

  • fromTry
  • fromEither
  • fromOption
  • fromIor

他のクラスからのコンバートする関数も用意されております。これらは直接使うよりは、それぞれのクラスに用意されている syntax である toValidated などを通して使う場面のほうが多そうです。

面白い関数

Validated は Either と似ているので、だいたい同じような関数を持っています(別名になっているものもあります)が、一部面白い関数があるので紹介しようと思います。

  • andThen
  • findValid
  • toValidatedNel
  • withEither
  • merge

andThen

まずは、前述しましたが、andThen 関数です。

def andThen[EE >: E, B](f: A => Validated[EE, B]): Validated[EE, B] = 
  this match {
    case Valid(a)       => f(a)
    case i @ Invalid(_) => i
  }

Either::flatMap と同じように fail fast に Validated をチェインしてくれる実装になっています。じゃあなんで、flatMap という名前にしないのかというと、flatMap という名前にすると、「Validated が Monad として扱うことになるのですが、そうなった場合 ap 関数と処理に矛盾が生じてしまうから。」という風なことが docs に書かれています。

ap は、エラーを累積する関数になっているのに対して、flatMap は fail fast として実装してしまうと、処理に一貫性がなくて色々やばいですよね。なので、Validated は Monad じゃないんですね。

使い方としては flatMap と同じ感じなので省略します。

findValid

続いては、findValid です。

def findValid[EE >: E, AA >: A](that: => Validated[EE, AA])(implicit EE: Semigroup[EE]): Validated[EE, AA] =
  this match {
    case v @ Valid(_) => v
    case Invalid(e) =>
      that match {
        case v @ Valid(_) => v
        case Invalid(ee)  => Invalid(EE.combine(e, ee))
      }
  }

名前の通り、valid を探すような優先度で2つの Validated を統合するような関数になっていますね。

orElse と似ていますが、どちらも失敗した場合、エラーを累積できる点が違う部分になっています。面白いですね。

toValidatedNel

続いては、toValidatedNel です。

def toValidatedNel[EE >: E, AA >: A]: ValidatedNel[EE, AA] =
  this match {
    case v @ Valid(_) => v
    case Invalid(e)   => Validated.invalidNel(e)
  }

こちらは、ただただ左側を NonEmptyList でラップしてあげるだけの関数ですが、型を合わせたい場合(fail fast として使っていたけど、累積する必要が出てきた場合とか)に使うシーンが出てくるかなと思います。

withEither

続いては withEither です。

def withEither[EE, B](f: Either[E, A] => Either[EE, B]): Validated[EE, B] =
  Validated.fromEither(f(toEither))

こちらは、一時的に Either として(つまり fail fast なエラーとして)処理をコントロールしたい場合に、便利な関数になっています。面白い関数ですね。

merge

続いては merge です。Either の標準ライブラリのでもありますが、それに比べると少し柔軟になっているようです。

def merge[EE >: E](implicit ev: A <:< EE): EE =
  this match {
    case Invalid(e) => e
    case Valid(a)   => ev(a)
  }

言い方があっているかわからないのですが、右側と左側の型が共通の型に由来しているような場合、ノーサイドな感じでその型に変換してくれるんですね。

例えばNonEmptyList[Throwable]を消費して、なにかに変換する場合、通常はパターンマッチなどで処理を書くと思いますが、右側の型に寄せてパターンマッチを書くような場合は、右側をそのまま渡すような無駄な記述をすることがあると思います。

sealed trait Response

case class ValidResponse() extends Response

case class InvalidResponse() extends Response

def response(validated: ValidatedNel[Throwable, ValidResponse]): Response =
  validated match {
    case Valid(valid) => valid
    case Invalid(_) => InvalidResponse()
  }

こういう場合は leftMap と merge を合わせるとシンプルに記述できると思います。

def response(validated: ValidatedNel[Throwable, ValidResponse]): Response =
  validated.leftMap(_ => InvalidResponse()).merge

終わりに

今回は、cats に定義されている Validated というデータ型について、Either との使い分けも交えてみていきました。標準で用意されている Either では書きづらいような処理を Validated を使うことによってシンプルに記述できる事がわかりました。

Validated 便利だな、使ってみたいな。と思っていただけたら幸いです。

なお、Either と Validated は Parallel という型クラスでつながっているもの同士になりますので、その性質を活かせばもっと良い感じにコードを書ける場面がたくさん出てくると思います。
ちゃんと理解できたらそこらへんも追記したいなと思っています。

次は cats に定義されているエラーの累積ができる Ior というデータ型についてみていけたらと思っています。Validated とは違うシーンで Either では書きづらいような処理をシンプルに書くことのできるようなデータ型になっていると思うので、こちらも使い分けできるといいなと思います。

1
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
1