/var/log/jsoizo

メモ帳 技術とか趣味とか

複数のバリデーション結果を蓄積したいときのEither<Nel<E>, A>とzipOrAccumurate

よくあるケースとして、入力値に対してバリデーションを行いすべて評価してから結果を返したい場合がある。
たとえばこのようなPerson型があったときに、

class Person private constructor(val name: Name, val age: Age)
  • name は空文字を許さない
  • age は0以上

の要件があったうえで nameが空文字でageが0 のような入力があったら、 1,2それぞれのエラーを返却したい。

こういうときにはArrow.ktのEither型を利用してバリデーションを実装した上で、zipOrAccumulateで蓄積していくのが良い。

class ValidationError(val field: String, val value: String, reason: String)

// コンパニオンオブジェクト以外からnewすることを防ぐ
data class Name private constructor(val value: String) {
    companion object {
        // ファクトリでバリデーションしOKならRight, ダメならLeft
        fun of(value: String): Either<ValidationError, Name> =
            if (value.isNotEmpty()) Name(value).right()
            else ValidationError("Name", value, "input is empty").left()
    }
}
data class Age private constructor(val value: Int) {
    companion object {
        fun of(value: Int): Either<ValidationError, Age> =
            if (value > 0) Age(value).right()
            else ValidationError("Age", value.toString(), "input is less than or equal 0").left()
    }
}

class Person private constructor(val name: Name, val age: Age) {
    companion object {
        fun of(name: String, age: Int): Either<Nel<ValidationError>, Person> = Either.zipOrAccumulate(
            Name.of(name),
            Age.of(age)
        ) { validatedName, validatedAge ->
            Person(validatedName, validatedAge)
        }
    }
}

この例の場合だと結果は以下のようになる。

  1. nameとage両方Right → Personのインスタンスが生成できる
  2. nameはRight, ageはLeft → ageのバリデーションエラーとなる
  3. nameはLeft, ageはRIght → nameのバリデーションエラーとなる
  4. nameとage両方Left → age, name両方のバリデーションエラーとなる

欠点として、 zipOrAccumulateでシュッと積み上げられるのが、 EIther<E, A> もしくは Either<NonEmptyList<E>, A> だけなので、例えば要件として

  • name に禁則文字を含まない

のような要件が追加され、 name に対する要件①と区別したい場合には、Nameのファクトリメソッド戻りの型がこのようになるはずである。

data class Name private constructor(val value: String) {
    companion object {
        fun of(value: String): Either<NonEmptyList<ValidationError>, Name> = TODO()
    }
}

こうするとNameとAgeでファクトリメソッドの型がそれぞれ Either<NonEmptyList<E>, A>Either<E, A> になり zipOrAccumurate で積み上げていくことができなくなるので、Ageのファクトリの型も同じように Either<NonEmptyList<E>, A> としなければならないのが難点である。

とはいえ、任意の値をNonEmptyListに含めるには .nel() すればよいだけなのでそこまで苦労しないはずである。

data class Age private constructor(val value: Int) {
    companion object {
        fun of(value: Int): Either<NonEmptyList<ValidationError>, Age> =
            if (value > 0) Age(value).right()
            else ValidationError("Age", value.toString(), "input is less than or equal 0").nel().left()
}