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 == "")
"no author").invalid()
BookError.AuthorError(else
author.valid()
}
fun BookDto.validateTitle(): Validated<BookError, String> {
return if (title == "")
"no title").invalid()
BookError.TitleError(else
title.valid()
}
fun Book.validateNaming(): Validated<BookError, Book> {
return if (title == author)
"author and title can't match").invalid()
BookError.NamingError(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 thezip
function. Arrow provides thezip
function which aggregates this validation error with all otherBookDto
validation errors. Thezip
function is defined on theValidatedNel
alias so we need aValidatedNel
instance.- the
zip
function calls the lambda{ author, title -> ...}
iff allValidatedNel
arguments contain theValid
instance. Otherwise it does not call the lambda and instead aggregates all validation errors from allValidatedNel
arguments. Thiszip
resembles the classiczip
function in that it “extracts” allValid
instances from allValidatedNel
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 whereandThen
comes in. It is applied to aValid
value but can return anInvalid
value instead.
That’s it. If we run the following code:
"Test", "Test")))
println(Book.create(BookDto("", "*** and Peace"))) // Russian edition
println(Book.create(BookDto("", "")))
println(Book.create(BookDto("John", "Arrows"))) println(Book.create(BookDto(
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)