🏭

安全で柔軟なAlways-Valid Domain Modelの作り方

2021/12/23に公開

はじめに

昨今ではますます Immutable な Domain Model の有用性が認知されているように思います。

また、Domain Model が持つ制約(不変条件)を満たさないインスタンスを作れないようにする事の重要性も広く知られてきており、Always-Valid Domain Model といった名称もついているようです。

ここでは、制約を満たしたDomain Model (Always-Valid Domain Model) しか作成できないようにするファクトリメソッドについて、より柔軟で合成可能性の高いファクトリメソッドを定義する方法を紹介します。

この記事で利用する言語とライブラリのバージョンは以下の通りです。

  • Scala 3.1.0
  • cats-core 2.7.0

最初のエラーで全体を失敗とするファクトリメソッド

まずは単純に Either を使ったファクトリメソッドを見てみましょう。

case class Name private (value: String)
object Name {
  def ofEither(value: String): Either[String, Name] = {
    if (value.nonEmpty) Right(Name(value))
    else Left("Name could not be blank.")
  }
}

String から Name を生成するファクトリメソッドを定義しました。

ここでは Name は空文字列ではないという制約を満たす必要があります。ofEither ファクトリメソッドではそれを表現するために戻り値を Either[String, Name] にしています[1]

制約を満たさない場合は Left[String] でエラーメッセージを返し、問題なく値が生成できた場合は Right[Name]Name インスタンスを返します。これで必ず制約を守ったインスタンスのみ生成できることが保証できます。

こういった条件を満たせば Right を返し満たさなければ Left を返す、というのは非常に頻出するので Either.cond という便利メソッドが提供されています。 これを使って書き直してみると以下のようになります。

case class Name private (value: String)
object Name {
  def ofEither(value: String): Either[String, Name] = {
    Either.cond(value.nonEmpty, Name(value), "Name could not be blank.")
  }
}

同様に別の制約を持つDomain Modelと、それらを保持するDomain Modelも定義してみます。

case class Age private (value: Int)
object Age {
  def ofEither(value: Int): Either[String, Age] = {
    Either.cond(value >= 0, Age(value), "Age could not be negative.")
  }
}
case class Person(name: Name, age: Age)
object Person {
  def ofEither(name: String, age: Int): Either[String, Person] = {
    for {
      name <- Name.ofEither(name)
      age  <- Age.ofEither(age)
    } yield Person(name, age)
  }
}

AgeName と似たようなクラスで、0以上という制約を満たす必要があります。

PersonNameAge を保持するクラスで、それぞれのファクトリメソッド Name.ofEitherAge.ofEither を使ってプリミティブな値から Person インスタンスを生成するファクトリメソッドが定義されています。

実際にREPLで使ってみましょう。

scala> Person.ofEither("", -1)
val res0: Either[String, Person] = Left(Name could not be blank.)

scala> Person.ofEither("gakuzzzz", 17)
val res1: Either[String, Person] = Right(Person(Name(gakuzzzz),Age(17)))

scala>

Person.ofEither("", -1) の様に不正な値を渡すと意図した通り Left でエラーメッセージが返され、Person.ofEither("gakuzzzz", 17) の様に適切な値を渡すと RightPerson インスタンスが返される事が確認できます。

ポイントとしては Person.ofEither("", -1) で渡している ""-1 はどちらもそれぞれ NameAge として不正な値です。ただしその結果は Left(Name could not be blank.)Name が返すエラーになっています。

これは、Person.ofEither が最初にエラーが見つかった時点で全体を失敗とし即座に結果を返す作りになっているためです。したがって Name.ofEither("") でエラーが返された段階で結果を返すため Age.ofEither(-1) は評価されず計算コストもかかりません。

この様な性質は Either の代わりに例外を使った実装でも同様の性質を持ちますね。

可能な限り全てのエラーを集めて返すファクトリメソッド

Eitherや例外を使うファクトリメソッドの問題

さて、これまでの様な実装でDomain Modelの生成には必ず制約を満たすという事は実現することができました。

ただし、これらのファクトリメソッドが例えば最終的にPublicなWeb APIの実装として呼び出されていた場合をイメージしてみてください。

そのWeb APIの利用者は {'name': '', 'age': -1} のようなリクエストを送って Status 400 {'error': 'Name could not be blank.'} みたいなレスポンスを受け取るわけです。

その人は「なるほど name が空文字列なのが悪いんだな」と理解して改めて {'name': 'foo', 'age': -1} というリクエストを送り直します。そうしたら今度は Age could not be negative. みたいなエラーが返されるのです。

そんな状況に陥った時、大抵の人は「最初から判ってるエラー全部返さんかーーーい!!!💢」と思うことでしょう。めちゃくちゃ使い辛いWeb APIだと思われてしまいますね。

このような不幸を避けるべく、ファクトリメソッドも可能な限り全てのエラーを集めて返す形にしたい所です。

その際に Either の代わりに使用できるデータ型が cats などで用意されている Validated です。

Validated

Either[L, R]Left[L]Right[R] で構成されているのと同様に、Validated[E, A]Invalid[E]Valid[A] で構成されています。

Either と異なる点は、Validated 同士を合成する際に両方とも Invalid だったら、その中身を連結してくれるという性質を持つ所にあります。今のエラーを集めたいという目的にぴったりですね。

という訳で、Either[String, ...] を返すファクトリメソッドの代わりに Validated[String, ...] を返すファクトリメソッドを定義すれば良さそうに思います。

ところが一つ問題があって、Validated のエラーの連結はその型の連結を使います[2]。従ってエラーの型が String のままだと単純に文字列連結になるので、NameAge の制約を両方満たさない時のエラーが "Name could not be blank.Age could not be negative." という一つの文字列になってしまうのです。

これは流石に使い勝手が悪すぎるので、エラーの型をコレクションでラッピングすることにしましょう。つまり Validated[List[String], ...] みたいな型にするわけですね。

NonEmptyChain

さて Validated[List[String], ...] の様に List を使っても良いのですが、List は非常に連結の計算量が多いデータ構造です。なので今回のように連結される事が前提の処理ではあまり使いたくありません。

そこで、List と同じく順序を維持したコレクションであり、連結操作の計算量が O(1) である Chain というデータ構造を使うことにします。

また、今回は Validated のエラー時の型として使うので、必ず空にならない事が保証できます[3]

そこで必ず空ではない事を型上で表現できる NonEmptyChain を利用します。つまり Validated[NonEmptyChain[String], ...] を使うという事ですね。

この Validated のエラー型に NonEmptyChain を利用するという使い方は非常に多く使われるので、タイプエイリアスが用意されています。その名も ValidatedNec です。

type ValidatedNec[E, A] = Validated[NonEmptyChain[E], A]

ではこの ValidatedNec を使用してファクトリメソッドを定義してみましょう。

ValidatedNec を使ったファクトリメソッドの実装

Name のファクトリメソッドの実装は以下のようになります。

case class Name private (value: String)
object Name {
  def ofValidated(value: String): ValidatedNec[String, Name] = {
    Validated.condNec(value.nonEmpty, Name(value), "Name could not be blank.")
  }
}

Either を使った版と比較してみましょう。

  def ofEither(value: String): Either[String, Name] = {
    Either.cond(value.nonEmpty, Name(value), "Name could not be blank.")
  }

  def ofValidated(value: String): ValidatedNec[String, Name] = {
    Validated.condNec(value.nonEmpty, Name(value), "Name could not be blank.")
  }

戻り値の型が Either から ValidatedNec になって、Either.cond のメソッド呼び出しが Validated.condNec に変わってるだけですね。

同様に Age のファクトリメソッドも定義しましょう。

case class Age private (value: Int)
object Age {
  def ofValidated(value: Int): ValidatedNec[String, Age] = {
    Validated.condNec(value >= 0, Age(value), "Age could not be negative.")
  }
}

そしてこれらのファクトリメソッドを使う Person のファクトリメソッドを定義します。

import cats.implicits.given // タプルの拡張メソッド mapN を使えるように

case class Person(name: Name, age: Age)
object Person {
  def ofValidated(name: String, age: Int): ValidatedNec[String, Person] = {
    (Name.ofValidated(name), Age.ofValidated(age)).mapN(Person.apply)
  }
}

ポイントは、 ValidatedflatMap を持たないため、Either の様に for式を使うことができない点です。また仮に for式が使えたとしても全てのエラーを先に取得したいので今回のケースでは利用できません。

そこで、まず NameAge の両方の結果である Validated をタプルにつめて、mapN メソッドに Person.apply を渡しています。mapN はタプルの要素が全て Valid であればその値を使って Person.apply を呼び出し、要素のいずれかが Invalid であれば全体を Invalid として返します。

Either 版と比べて見ましょう。

  def ofEither(name: String, age: Int): Either[String, Person] = {
    for {
      name <- Name.ofEither(name)
      age  <- Age.ofEither(age)
    } yield Person(name, age)
  }

  def ofValidated(name: String, age: Int): ValidatedNec[String, Person] = {
    (Name.ofValidated(name), Age.ofValidated(age)).mapN(Person.apply)
  }

では実際にREPLで使ってみましょう。

scala> Person.ofValidated("", -1)
val res2: Validated[NonEmptyChain[String], Person] =
  Invalid(Chain(Name could not be blank., Age could not be negative.))

scala> Person.ofValidated("gakuzzzz", 17)
val res3: Validated[NonEmptyChain[String], Person] =
  Valid(Person(Name(gakuzzzz),Age(17)))

scala>

Person.ofValidated("", -1) で両方のエラーメッセージが返されている事が見て取れますね。

これで無事にエラーを集約するファクトリメソッドを定義する事ができました。めでたしめでたし。

利用者が短絡か集約かを選択できるファクトリメソッド

さて、これで最初にエラーが見つかり次第即座に全体を失敗にして返すファクトリメソッドと、可能な限り全てのエラーを集約して返すファクトリメソッドの両方を用意することができました。

ただこれを全てのDomain Modelで両方定義していくのは非常に面倒です。

どうせなら一つのファクトリメソッドで、利用者が結果を Either で得るか Validated で得るかを選択できると嬉しいですよね。果たしてそんなメソッドを定義することが可能なのでしょうか?

実は可能なんです。

それを可能とする秘密兵器が ApplicativeError になります。

ApplicativeError は抽象的な概念なので、どんなもなのか言葉で解説するよりも実際に使い方を見て頂くほうがわかりやすいと思います。さくさく実装に進みましょう。

case class Name private (value: String)
object Name {
  def of[F[_, _]](value: String)(
    using AE: ApplicativeError[F[NonEmptyChain[String], *], NonEmptyChain[String]]
  ): F[NonEmptyChain[String], Name] = {
    if (value.nonEmpty) AE.pure(Name(value))
    else AE.raiseError(NonEmptyChain.one("Name could not be blank."))
  }
}

シグネチャが恐ろしくややこしくなりましたが、少しずつ見ていきましょう。

  def of[F[_, _]](value: String)(

まず def of[F[_, _]] でメソッドスコープの型引数 F が導入されています。 この F には EitherValidated を指定する想定ですので、それ自身が型引数を2つ持つ事を示すように F[_, _] としています。

そして通常の引数グループである (value: String) が宣言されています。これは今までのファクトリメソッドと同じですね。

    using AE: ApplicativeError[F[NonEmptyChain[String], *], NonEmptyChain[String]]

そして利用する型クラスとして (using AE: ApplicativeError[F[NonEmptyChain[String], *], NonEmptyChain[String]]) が宣言されています。ここが非常にわかりにくいですが「メソッドスコープの型引数 F のエラー型として NonEmptyChain[String] を利用し、その F に合致した ApplicativeError 型クラスを使うよ」ぐらいの意味だと認識して頂ければここでは十分です。

  ): F[NonEmptyChain[String], Name] = {

戻り値の型が F[NonEmptyChain[String], Name] になっているので、メソッド呼び出し時に of[Either](...) と呼び出せば Either[NonEmptyChain[String], Name] が結果になり、 of[Validated](...) と呼び出せば Validated[NonEmptyChain[String], Name] (つまりValidatedNec[String, Name]) が結果になる、という訳ですね。

    if (value.nonEmpty) AE.pure(Name(value))
    else AE.raiseError(NonEmptyChain.one("Name could not be blank."))

メソッドの中身は単純な if式で、制約を満たしていれば値を pure で包んで返し、満たしていなければ NonEmptyChain にエラーメッセージを突っ込んだものをエラーとして raiseError している、という形です。

同様に Age のファクトリメソッドも定義しましょう。

case class Age private (value: Int)
object Age {
  def of[F[_, _]](value: Int)(
      using AE: ApplicativeError[F[NonEmptyChain[String], *], NonEmptyChain[String]]
  ): F[NonEmptyChain[String], Age] = {
    if (value >= 0) AE.pure(Age(value))
    else AE.raiseError(NonEmptyChain.one("Age could not be negative."))
  }
}

次に Person のファクトリメソッドですね。

case class Person(name: Name, age: Age)
object Person {
  def of[F[_, _]](name: String, age: Int)(
      using AE: ApplicativeError[F[NonEmptyChain[String], *], NonEmptyChain[String]]
  ): F[NonEmptyChain[String], Person] = {
    AE.map2Eval(Name.of(name), Later(Age.of(age)))(Person.apply).value
  }
}

シグネチャは NameAge のファクトリメソッドとほぼほぼ同じです。

実装については ofValidated の時と同じく以下のような実装でも意図した動作はします。

    (Name.of(name), Age.of(age)).mapN(Person.apply)

ただし、これだと Name.ofAge.of の両方を評価してタプルを作成するので、仮に Either を指定した場合でも両方のファクトリメソッドが実行されます。

Either を指定した場合、せっかくなら短絡評価をして先にエラーが見つかった場合は後続の処理をさせないようにしたいですね。そこで利用できるのが map2Eval です。

    AE.map2Eval(Name.of(name), Later(Age.of(age)))(Person.apply).value

Age.ofLater で包むことによって評価を遅延させる事ができ、map2Eval が必要としなければ Age.of の呼び出しを省略することができます。

では実際にREPLで使ってみましょう。

scala> Person.of[Either]("", -1)
val res4: Either[NonEmptyChain[String], Person] = 
  Left(Chain(Name could not be blank.))

scala> Person.of[Validated]("", -1)
val res5: Validated[NonEmptyChain[String], Person] = 
  Invalid(Chain(Name could not be blank., Age could not be negative.))

scala> Person.of[Either]("gakuzzzz", 17)
val res6: Either[NonEmptyChain[String], Person] = 
  Right(Person(Name(gakuzzzz),Age(17)))

scala> Person.of[Validated]("gakuzzzz", 17)
val res7: Validated[NonEmptyChain[String], Person] =
  Valid(Person(Name(gakuzzzz),Age(17)))

scala>

Person.of[Either]("", -1)Either を指定した場合には Name could not be blank. しか返ってきませんが、 Person.of[Validated]("", -1)Validated を指定した場合には Name could not be blank.Age could not be negative. の両方が返ってきてることが見て取れるかと思います。大変素晴らしいですね。

これで利用者が結果を Either で得るか Validated で得るかを選択できるファクトリメソッドを定義する事ができました。めでたしめでたし。

Always-Valid Domain Model のファクトリメソッドはみんなこんな感じで定義されてると嬉しいですね。

[おまけ] 使い勝手の改善

この記事の本題は前章までなのですが、実は前章で定義したファクトリメソッドには一つ手抜きがありました。というのも EitherValidated を統一的に扱うために、エラーの型を NonEmptyChain に固定してしまった事ですね。

そのため、Person.of[Either] の戻り値型も Either[NonEmptyChain[String], Person] 型になってしまっています。Either の時はエラーは一つとわかっているのだから、Either[String, Person] で返してほしいですよね。

Either を指定した時は String を返し、Validated を指定した時は NonEmptyChain[String] を返す。そんなメソッドが定義可能なのでしょうか?

実はこれもできてしまいます。

ある型のときにはあちらの型を、また別の型の時にはそちらの型を、といった事をしたい場合、型クラスに抽象型メンバを持たせる事で実現できます。

具体的に見ていきましょう。今回はエラーを集約する型を導出したいので、ErrorCollective という型クラスを作ることにします。

trait ErrorCollective[F[_, _]] {
  type G[E]
  def applicativeError[E]: ApplicativeError[F[G[E], *], G[E]]
  protected def oneOfError[E](e: E): G[E]
  def cond[E, A](p: => Boolean, ifTrue: => A, ifFalse: => E): F[G[E], A] = {
    if (p) applicativeError.pure(ifTrue)
    else applicativeError.raiseError(oneOfError(ifFalse))
  }
}

EitherValidated で使い分けたいので、この型クラスには F[_, _] と2つの型引数を持つ型を指定できるようにします。

そしてエラーの型を抽象型メンバとして type G[E] を定義します。

後は便利メソッドの追加です。この型クラスの利用側で ApplicativeError を利用することは判っているので、利用側のシグネチャを簡素にできるように ApplicativeError インスタンス自体を ErrorCollective に持たせてしまいます。

同様によく使う Either.cond のようなメソッドも提供しておきます。

この ErrorCollective のインスタンス定義が以下のようになります。

object ErrorCollective {
  given ErrorCollective[Either] with {
    type G[E] = E
    def applicativeError[E] = summon
    protected def oneOfError[E](e: E): G[E] = e
  }
  given ErrorCollective[Validated] with {
    type G[E] = NonEmptyChain[E]
    def applicativeError[E] = summon
    protected def oneOfError[E](e: E): G[E] = NonEmptyChain.one(e)
  }
}

Either の場合は、エラーの型である抽象型メンバ G[E] の実体をそのまま E とし、Validated の場合は NonEmptyChain[E] にします。

そしてファクトリメソッドは ErrorCollective を使い以下のように宣言します。

case class Name private (value: String)
object Name {
  def of2[F[_, _]](value: String)
      (using EC: ErrorCollective[F]): F[EC.G[String], Name] = {
    EC.cond(value.nonEmpty, Name(value), "Name could not be blank.")
  }  
}

前章の実装と比べて大変簡素になりました。比較するとこんな感じです。

  def of[F[_, _]](value: String)(
      using AE: ApplicativeError[F[NonEmptyChain[String], *], NonEmptyChain[String]]
  ): F[NonEmptyChain[String], Name] = {
    if (value.nonEmpty) AE.pure(Name(value))
    else AE.raiseError(NonEmptyChain.one("Name could not be blank."))
  }

  def of2[F[_, _]](value: String)
      (using EC: ErrorCollective[F]): F[EC.G[String], Name] = {
    EC.cond(value.nonEmpty, Name(value), "Name could not be blank.")
  }

ポイントは戻り値の型が F[EC.G[String], Name] になっている事ですね。ErrorCollective 型の引数 EC のメンバ型である G つまり EC.G[String] が結果のエラー型になっています。

従って Either が指定された場合は EC.G[String]String になり、Validated が指定された場合は EC.G[String]NonEmptyChain[String] になるわけです。

さくさくと AgePerson も定義しましょう。

case class Age private (value: Int)
object Age {
  def of2[F[_, _]](value: Int)
      (using EC: ErrorCollective[F]): F[EC.G[String], Age] = {
    EC.cond(value >= 0, Age(value), "Age could not be negative.")
  }
}
case class Person(name: Name, age: Age)
object Person {
  def of2[F[_, _]](name: String, age: Int)
      (using EC: ErrorCollective[F]): F[EC.G[String], Person] = {
    EC.applicativeError
      .map2Eval(Name.of2(name), Later(Age.of2(age)))(Person.apply).value
  }
}

では実際にREPLで使ってみましょう。

scala> Person.of2[Either]("", -1)
val res8: Either[String, Person] = Left(Name could not be blank.)

scala> Person.of2[Validated]("", -1)
val res9: Validated[NonEmptyChain[String], Person] =
  Invalid(Chain(Name could not be blank., Age could not be negative.))

scala> Person.of2[Either]("gakuzzzz", 17)
val res10: Either[String, Person] = Right(Person(Name(gakuzzzz),Age(17)))

scala> Person.of2[Validated]("gakuzzzz", 17)
val res11: Validated[NonEmptyChain[String], Person] =
  Valid(Person(Name(gakuzzzz),Age(17)))

scala>

意図通り Either を指定した場合の結果が Either[String, Person] に、Validated を指定した場合の結果が Validated[NonEmptyChain[String], Person] になっていますね。

[おまけ] 親方!空から非同期APIが!

こんな感じで便利な ApplicativeError ですが、実は非同期計算を行うための Future にも ApplicativeError インスタンスは用意されています。

従って以下のようなものを用意してあげると

  given (using ExecutionContext): ErrorCollective[[E, A] =>> Future[A]] with {
    type G[E] = Throwable
    def applicativeError[E] = summon
    protected def oneOfError[E](e: E): G[E] = new Exception(e.toString)
  }

途端にファクトリメソッドを非同期APIにすることができてしまいます。

scala> import scala.concurrent.Future

scala> import scala.concurrent.ExecutionContext.Implicits.global

scala> Person.of2[[E, A] =>> Future[A]]("", -1)
val res12: Future[Person] = Future(Failure(Name could not be blank.))

scala> Person.of2[[E, A] =>> Future[A]]("gakuzzzz", 17)
val res13: Future[Person] = Future(<not completed>)

scala>

便利ですね?

Appendix

以下ソースコードを全量載せておきます。

/*
 * ApplicativeError Sample
 *
 * Copyright(c) gakuzzzz
 *
 * This software is released under the MIT License.
 * http://opensource.org/licenses/mit-license.php
 */
package jp.t2v.lab.errorsample

import cats.data.{NonEmptyChain, Validated, ValidatedNec}
import cats.{Applicative, ApplicativeError, CommutativeApplicative, Later}
import cats.implicits.given

import scala.concurrent.{ExecutionContext, Future}

case class Name private (value: String)
object Name {

  def ofEither(value: String): Either[String, Name] = {
    Either.cond(value.nonEmpty, Name(value), "Name could not be blank.")
  }

  def ofValidated(value: String): ValidatedNec[String, Name] = {
    Validated.condNec(value.nonEmpty, Name(value), "Name could not be blank.")
  }

  def of[F[_, _]](value: String)(
      using AE: ApplicativeError[F[NonEmptyChain[String], *], NonEmptyChain[String]]
  ): F[NonEmptyChain[String], Name] = {
    if (value.nonEmpty) AE.pure(Name(value))
    else AE.raiseError(NonEmptyChain.one("Name could not be blank."))
  }

  def of2[F[_, _]](value: String)
      (using EC: ErrorCollective[F]): F[EC.G[String], Name] = {
    EC.cond(value.nonEmpty, Name(value), "Name could not be blank.")
  }

}

case class Age private (value: Int)
object Age {

  def ofEither(value: Int): Either[String, Age] = {
    Either.cond(value >= 0, Age(value), "Age could not be negative.")
  }

  def ofValidated(value: Int): ValidatedNec[String, Age] = {
    Validated.condNec(value >= 0, Age(value), "Age could not be negative.")
  }

  def of[F[_, _]](value: Int)(
      using AE: ApplicativeError[F[NonEmptyChain[String], *], NonEmptyChain[String]]
  ): F[NonEmptyChain[String], Age] = {
    if (value >= 0) AE.pure(Age(value))
    else AE.raiseError(NonEmptyChain.one("Age could not be negative."))
  }

  def of2[F[_, _]](value: Int)
      (using EC: ErrorCollective[F]): F[EC.G[String], Age] = {
    EC.cond(value >= 0, Age(value), "Age could not be negative.")
  }

}

case class Person(name: Name, age: Age)
object Person {

  def ofEither(name: String, age: Int): Either[String, Person] = {
    for {
      name <- Name.ofEither(name)
      age  <- Age.ofEither(age)
    } yield Person(name, age)
  }

  def ofValidated(name: String, age: Int): ValidatedNec[String, Person] = {
    (Name.ofValidated(name), Age.ofValidated(age)).mapN(Person.apply)
  }

  def of[F[_, _]](name: String, age: Int)(
      using AE: ApplicativeError[F[NonEmptyChain[String], *], NonEmptyChain[String]]
  ): F[NonEmptyChain[String], Person] = {
    AE.map2Eval(Name.of(name), Later(Age.of(age)))(Person.apply).value
  }

  def of2[F[_, _]](name: String, age: Int)
      (using EC: ErrorCollective[F]): F[EC.G[String], Person] = {
    EC.applicativeError
      .map2Eval(Name.of2(name), Later(Age.of2(age)))(Person.apply).value
  }

}

trait ErrorCollective[F[_, _]] {
  type G[E]
  def applicativeError[E]: ApplicativeError[F[G[E], *], G[E]]
  def cond[E, A](p: => Boolean, ifTrue: => A, ifFalse: => E): F[G[E], A] = {
    if (p) applicativeError.pure(ifTrue)
    else applicativeError.raiseError(oneOfError(ifFalse))
  }
  protected def oneOfError[E](e: E): G[E]
}
object ErrorCollective {
  given ErrorCollective[Either] with {
    type G[E] = E
    def applicativeError[E] = summon
    protected def oneOfError[E](e: E): G[E] = e
  }
  given ErrorCollective[Validated] with {
    type G[E] = NonEmptyChain[E]
    def applicativeError[E] = summon
    protected def oneOfError[E](e: E): G[E] = NonEmptyChain.one(e)
  }
  given (using ExecutionContext): ErrorCollective[[E, A] =>> Future[A]] with {
    type G[E] = Throwable
    def applicativeError[E] = summon
    protected def oneOfError[E](e: E): G[E] = new Exception(e.toString)
  }
}
脚注
  1. 業務コードでは制約を満たさないエラー時に返すものは String よりも何らかのenum等にした方が網羅性検査などが活用できるのでお勧めです。ここではサンプルコードとしての単純さを優先するために String にしています。ご容赦頂けたら幸いです。 ↩︎

  2. 具体的には Semigroup という型クラスを利用して連結を行います。 ↩︎

  3. エラーが空という事は必ず Valid になるからですね。 ↩︎

Discussion