Validations using Kotlin Arrow

[2022-03-26]

A short example how to validate data objects using Arrow and Validations

Currently, I am exploring the Arrow library in Kotlin. At work, I need to validate Java DTOs and handle any data related problems that might arise. Arrows Validated seemed like a perfect fit for the task. I struggled a bit to understand the API so I decided to give it a try and create a simple example.

I will be validating the following data class:

open class BookDto(val author: String, val title: String, val noValidationNeeded: String = "unimportant")

From this BookDto we want to create an (immutable) instance of class Book where all fields are correct.

Each validation error will have its own data type. We will recognize these errors:

sealed class BookError {
    data class AuthorError(val msg: String) : BookError()
    data class TitleError(val msg: String) : BookError()
    data class NamingError(val msg: String) : BookError()
}

Validated has a similar structure as the Either type i.e.:

Validated<BookError, DataWeWant>

where DataWeWant is populated if no validation erros occur. BookError represents an error if data was incorrect.

We can define validations for author, title and a combination of those two in the following way:

fun BookDto.validateAuthor(): Validated<BookError, String> {
    return if (author == "")
        BookError.AuthorError("no author").invalid()
    else
        author.valid()
}

fun BookDto.validateTitle(): Validated<BookError, String> {
    return if (title == "")
        BookError.TitleError("no title").invalid()
    else
        title.valid()
}

fun Book.validateNaming(): Validated<BookError, Book> {
    return if (title == author)
        BookError.NamingError("author and title can't match").invalid()
    else
        this.valid()
}

The valid() and invalid() are Arrow extensions functions which just wrap the receiver in Arrows Valid and Invalid instances. Why is the last validation extension defined on the Book class and not BookDto? I will explain later.

Before we define the Book instance let’s look at how the Book will be instantiated:

val bookDto = BookDto("Test", "Test")
val book = Book.create(BookDto)

The signature for create is:

fun create(aBookDto: BookDto): ValidatedNel<BookError, Book>

What is ValidatedNel? Shouldn’t create return Validated like all the validation extension functions?

The reason for returning ValidatedNel is that Validated represents only one validation error but ValidatedNel represents a list of errors. ValidatedNel is a type alias for:

ValidatedNel<E, A> = Validated<Nel<E>, A>

where Nel is a type alias for Nel<A> = NonEmptyList<A>. In other words ValidatedNel represents all validation errors that have been detected in BookDto.

And now the most challenging part - the definition of the create function in Book:

class Book private constructor(author: String, title: String, irrelevant: String) :
    BookDto(author, title, irrelevant) {
    companion object {
        fun create(aBookDto: BookDto): ValidatedNel<BookError, Book> {
            return aBookDto.validateAuthor().toValidatedNel()
                .zip(aBookDto.validateTitle().toValidatedNel()) { author, title ->
                    Book(
                        author,
                        title,
                        aBookDto.noValidationNeeded
                    )
                }.andThen { it.validateNaming().toValidatedNel() }
        }
    }
}

This might seem a bit complicated so let’s break it down:

  • aBookDto.validateAuthor().toValidatedNel() this calls our validation extension function. It returns at most 1 validation error. So why do we call toValidatedNel() at the end? Because we need to call the zip function. Arrow provides the zip function which aggregates this validation error with all other BookDto validation errors. The zip function is defined on the ValidatedNel alias so we need a ValidatedNel instance.
  • the zip function calls the lambda { author, title -> ...} iff all ValidatedNel arguments contain the Valid instance. Otherwise it does not call the lambda and instead aggregates all validation errors from all ValidatedNel arguments. This zip resembles the classic zip function in that it “extracts” all Valid instances from all ValidatedNel and combines them together using the lambda expression.
  • the zip function is currently limited to 22 arguments but we might want to do more validations. Also the validations might check multiple fields at the same time. This is where andThen comes in. It is applied to a Valid value but can return an Invalid value instead.

That’s it. If we run the following code:

println(Book.create(BookDto("Test", "Test")))
println(Book.create(BookDto("", "*** and Peace"))) // Russian edition
println(Book.create(BookDto("", "")))
println(Book.create(BookDto("John", "Arrows")))

we get:

Validated.Invalid(NonEmptyList(NamingError(msg=author and title can't match)))
Validated.Invalid(NonEmptyList(AuthorError(msg=no author)))
Validated.Invalid(NonEmptyList(AuthorError(msg=no author), TitleError(msg=no title)))
Validated.Valid(Book@1445d7f)