Search code examples
kotlinarrow-kt

Validate an object with Arrow-kt


I have an object (book), what fields should get updated by an event (author changed). Lets say the author field of the book only changes if the author has married and changed his name, but the book won't change if the author just moved to a new city.

In this simple case I could check if book.authorName == event.author.name and return an Either<NothingChangedFailure, Book>. But how could I check on more than just one field? If I'd go forward with Either the process would stop on the first NothingChangedFailure it encounters, but I would like to aggregate all updates and only return NothingChangedFailure if none of the fields in book have changed.

I tried with Option, Either and read on Validated but I they all seem to fail the entire result If a single Failure was risen. So is there an option I just don't see?


Solution

  • There is an example on Validated that shows a case where we can compose validation failures.

    For your case (I'm gonna assume things here, like the fields available in a book) I guess it would look something like:

    data class Book(val title: String, val authorName: String, val pageCount: Int)
    

    Here we create the errors with a definition of Semigroup for it:

    sealed class BookValidationError {
      data class PropertyNotChanged(val propertyName: String) : BookValidationError()
      data class Multiple(val errors: Nel<BookValidationError>) : BookValidationError()
    }
    
    object BookValidationErrorSemigroup : Semigroup<BookValidationError> {
      override fun BookValidationError.combine(b: BookValidationError): BookValidationError = when {
          this is Multiple && b is Multiple -> Multiple(errors + b.errors)
          this is Multiple && b !is Multiple -> Multiple(errors + b)
          this !is Multiple && b is Multiple -> Multiple(this.nel() + b.errors)
          else -> BookValidationError.Multiple(NonEmptyList(this, b))
      } 
    }
    

    Then we can define the relevant ApplicativeError for the error types:

    private val bookApplicativeError : ApplicativeError<ValidatedPartialOf<BookValidationError>, BookValidationError> = 
      Validated.applicativeError(BookValidationErrorSemigroup)
    

    And we bring it together with a helper class:

    class BookValidation(
      private val book: Book
    ) : ApplicativeError<ValidatedPartialOf<BookValidationError>, BookValidationError> by bookApplicativeError {
    
        fun <T> fieldIsNot(name: String, actualValue: T, incorrectValue: T): Kind<ValidatedPartialOf<BookValidationError>, Book> =
            if(actualValue == incorrectValue) raiseError(BookValidationError.PropertyNotChanged(name))
            else just(book)
    
    }
    

    and an easy access extension function:

    fun Book.validateThat(titleIsNot : String, authorNameIsNot: String, pageCountIsNot: Int) = 
        with(BookValidation(this)) {
            map(
                fieldIsNot("title", title, titleIsNot), 
                fieldIsNot("authorName", authorName, authorNameIsNot),
                fieldIsNot("pageCount", pageCount, pageCountIsNot)
            ) { this@validateThat }.handleErrorWith { 
                raiseError(it) 
            }
        }
    
    

    Then, if you execute it like:

    fun main() {
        Book("a", "b", 123).validateThat(
            titleIsNot = "c",
            authorNameIsNot = "d",
            pageCountIsNot = 124
        ).let(::println)
        Book("a", "b", 123).validateThat(
            titleIsNot = "a",
            authorNameIsNot = "b",
            pageCountIsNot = 123
        ).let(::println)
        Book("a", "b", 123).validateThat(
            titleIsNot = "c",
            authorNameIsNot = "b",
            pageCountIsNot = 124
        ).let(::println)
    }
    

    The first one will be valid with the following output:

    Valid(a=Book(title=a, authorName=b, pageCount=123))
    

    But the second one will output:

    Invalid(e=Multiple(errors=NonEmptyList(all=[PropertyNotChanged(propertyName=pageCount), PropertyNotChanged(propertyName=title), PropertyNotChanged(propertyName=authorName)])))
    

    Inside of this Invalid instance we have a NonEmptyList that contains all the fields that failed the validation. If we reformat a bit the output we can see them:

    Invalid(e=Multiple(
      errors=NonEmptyList(all=[
        PropertyNotChanged(propertyName=pageCount), 
        PropertyNotChanged(propertyName=title), 
        PropertyNotChanged(propertyName=authorName)
      ])
    ))
    

    Now, for the third case, since only one of them remains the same we get the following output:

    Invalid(e=PropertyNotChanged(propertyName=authorName))