Validations using Kotlin Arrow
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 calltoValidatedNel()at the end? Because we need to call thezipfunction. Arrow provides thezipfunction which aggregates this validation error with all otherBookDtovalidation errors. Thezipfunction is defined on theValidatedNelalias so we need aValidatedNelinstance.- the
zipfunction calls the lambda{ author, title -> ...}iff allValidatedNelarguments contain theValidinstance. Otherwise it does not call the lambda and instead aggregates all validation errors from allValidatedNelarguments. Thiszipresembles the classiczipfunction in that it “extracts” allValidinstances from allValidatedNeland combines them together using the lambda expression. - the
zipfunction 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 whereandThencomes in. It is applied to aValidvalue but can return anInvalidvalue 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)