Monday, February 28, 2022

Kotlin: Validation type

Kotlin: Validation type

Any system will have errors and how we handle them is important. Consistent and transparent error handling is critical to any production ready system. Here we explore the functional approach to error handling, developing techniques to capture errors elegantly without contaminating your code with ugly conditionals and try/catch clauses. We consider the Validation class included in the custom library Dogs.



Motivation

Validation can be found in different forms when error(s) are detected. Validation can return immediately when the first error (or exception) has been encountered; the validation result may or may not contain the validation error or exception message. This scenario is called fast failing validation, in which the validation does not validate all the business rules and only zero or one message is returned, and the process shall be cut short upon first error. This simple form of validation is sometimes considered insufficient, as a full validation is not carried out with accumulated errors. See the Either type blog on fast failing.

Validating all the business rules, and accumulating errors, is very different from fast failing validation. Applicative functors are proposed, and they have effectively solved accumulation problems. The Validation data type has an instance of applicative that accumulates on the error side.

The Validation data type is isomorphic to Either. Validation is a container type with two type parameters: A Validation<E, A> instance can contain either an instance of E, or an instance of A (a disjoint type). Validation has exactly two sub types, Failure and Success. If a Validation<E, A> object contains an instance of E, then the Validation is a Failure. Otherwise it contains an instance of A and is a Success. The Validation sealed class is:

sealed class Validation<out E, out A> {

    class Failure<out E, out A>internal constructor(val value: E) : Validation<E, A>()
    class Success<out E, out A>internal constructor(val value: A) : Validation<E, A>()

}

As before, the constructors for Failure and Success are internal and we create instances with the factory functions:

val failed: Validation<String, Int> = failure("bug")
val succeeded: Validation<String, Int> = success(25)

assertEquals(true,      failed.isFailure())
assertEquals(false,     succeeded.isFailure())

Validation is a general purpose type for use with error handling:

fun parseInt(text: String): Validation<String, Int> =
    try {
        success(Integer.parseInt(text))
    } catch (ex: NumberFormatException) {
        failure("parseInt: bad number format: $text")
    }

assertEquals(success(123),                                  parseInt("123"))
assertEquals(failure("parseInt: bad number format: ken"),   parseInt("ken"))



Pattern matching

Like in the Option class the ValidationFailure and Success type names can be used in application code. The type names can be used in user-defined functions. The function swap supports mapping over both arguments at the same time. Its signature is shown in the following code.

fun <E, A> Validation<E, A>.swap(): Validation<A, E> {
    return when(this) {
        is Failure -> success(this.value)
        is Success -> failure(this.value)
    }
}   // swap

val failed: Validation<String, Int> = failure("bug")
val succeeded: Validation<String, Int> = success(25)

assertEquals(success("bug"),    failed.swap())
assertEquals(failure(25),       succeeded.swap())


The function swap flips the Failure/Success values.



Some Validation operations

The Validation class includes many of the functions we saw with the Either class. Validation class functions include exists, fold and map. The map function has the signature:

fun <B> map(f: (A) -> B): Validation<E, B>

and applies function if this is a Success.

val failed: Validation<String, Int> = failure("bug")
val succeeded: Validation<String, Int> = success(25)

assertEquals(failure("bug"),    failed.map(isEven))
assertEquals(success(false),    succeeded.map(isEven))
assertEquals(success(true),     succeeded.map(isOdd))


The Validation type is also an applicative functorThe Validation type as an applicative functor includes the extension function ap:

fun <E, A, B> Validation<E, A>.ap(se: Semigroup<E>, f: Validation<E, (A) -> B>): Validation<E, B>

which applies the wrapped function to the receiver Validation. If two or more errors are encountered they are combined using the semigroup instance.

val failed: Validation<String, Int> = failure("bug")
val succeeded: Validation<String, Int> = success(25)

assertEquals(failure("bug"),      failed.ap(stringSemigroup, success{n: Int -> isEven(n)}))
assertEquals(success(false),      succeeded.ap(stringSemigroup, success{n: Int -> isEven(n)}))


When mapping functions over the Validation functor with fmap/map we have provided a unary function for the mapping. What do we get if we provide a curried binary function? The answer is we get a unary function wrapped in a Validation as shown for the value binding vf and vs.

val failed: Validation<String, Int> = failure("bug")
val err: Validation<String, Int> = failure("error")
val succeeded: Validation<String, Int> = success(25)

val vf: Validation<String, (Int) -> Int> = {m: Int -> {n: Int -> m + n}} dollar failed
val vs: Validation<String, (Int) -> Int> = {m: Int -> {n: Int -> m + n}} dollar succeeded

assertEquals(success(50),           succeeded.ap(stringSemigroup, vs))
assertEquals(failure("errorbug"),   err.ap(stringSemigroup, vf))

Suppose we need to validate a person's name and age where the values are supplied through two text values. The classes we wish to construct from the text are:

data class Name(val name: String)
data class Age(val age: Int)
data class Person(val name: Name, val age: Age)


A name is valid provided it is non-empty and an age is valid if it is non-negative. The checks are made with the functions makeName and makeAge:

fun makeName(name: String): Validation<String, Name> =
    if (name == "") failure("Name is empty") else success(Name(name))

fun makeAge(age: Int): Validation<String, Age> =
    if (age < 0) failure("Age out of range") else success(Age(age))


We are using Validation with the result of these two validation functions. To combine the results we use the applicative fmap2 function, a binary version of fmap:

fun makePerson(name: String, age: Int): Validation<String, Person> =
    fmap2(stringSemigroup, makeName(name), makeAge(age)){name: Name -> {age: Age -> Person(name, age)}}

In the first assert we have an invalid age. In the second assert we have an invalid name and an invalid age. The string semigroup concatenates the two failure messages. The final assertion makes a valid Person instance.

assertEquals(failure("Age out of range"),               makePerson("Ken", -5))
assertEquals(failure("Age out of rangeName is empty"),  makePerson("", -5))
assertEquals(success(Person(Name("Ken"), Age(25))),     makePerson("Ken", 25))



Case Study

We repeat here the second case study introduced in the Either type blog. In this a user form comprises text fields for the user last name, the first name and the email. The requirements are that the last name is fully capitalized, the first name is capitalized on the initial letter, and that the email be correctly structured. After accepting the three fields, objects for the classes LastNameFirstName and Email are constructed then used to create an instance of the Person class.

The standard technique for the construction of objects that need to honor a set of constraints is the smart constructor idiom. This is illustrated for the class LastName, using Validation for the error handling. Since it is meaningless to have a Failure with no errors then we use the type Failure<NonEmptyList<Error>, A> where Error is some error type. We start with:

typealias Error = String
typealias ValidationNelError<A> = ValidationNel<Error, A>

Typically, Error would be a sealed data class with sub-types for the various error types. The type name ValidationNel is provided by Dogs and is an alias for Validation<NonEmptyList<E>, A>. The application alias ValidationNelError provides a compact representation for Validation<NonEmptyList<Error>, A>.

The class LastName is:

data class LastName(val lastName: String) {

    companion object {

        fun create(lastName: String): ValidationNelError<LastName> {
            return if (lastName.length < 2)
                failureNel("Last name too short: $lastName")
            else if (!regex.matches(lastName))
                failureNel("Last name malformed: $lastName")
            else
                successNel(LastName(lastName))
        }   // create

        private val regex: Regex = Regex("[A-Z][A-Z]*")
    }

}   // LastName

The same scheme is used for the classes FirstName and Email.

Since the Either class is a monad we used the monadic bind to implement the smart constructor for the Person class. However, the Validation class is not a monad and we have no bind function. The issue is that the applicative functor implied by Validation being a monad does not equal the applicative functor defined on Validation.

We also know that the monad fails fast and can only identify the first error. Applicatives allow us to compose independent operations and evaluate each one. Even if an intermediate evaluation fails. This allows us to collect error messages instead of returning only the first error that occurred. A classic example where this is useful is the validation of user input. We would like to return a list of all invalid inputs rather than aborting the evaluation after the first error. We see this in the Person class:

data class Person(val lastName: LastName, val firstName: FirstName, val email: Email) {

    companion object {

        fun create(lastName: String, firstName: String, email: String): ValidationNelError<Person> {
            val vLastName: ValidationNelError<LastName> = LastName.create(lastName)
            val vFirstName: ValidationNelError<FirstName> = FirstName.create(firstName)
            val vEmail: ValidationNelError<Email> = Email.create(email)

            val ctor: (LastName) -> (FirstName) -> (Email) -> Person = C3(::Person)
            return ctor dollar vLastName appliedOver vFirstName appliedOver vEmail
        }   // create

    }

}   // Person

and in the following three examples:

assertEquals(
    success(Person(LastName("BARCLAY"), FirstName("Ken"), Email("me@gmail.com"))),
    Person.create("BARCLAY", "Ken", "me@gmail.com")
)
assertEquals(
    failure(NonEmptyListF.singleton("First name malformed: kenneth")),
    Person.create("BARCLAY", "kenneth", "me@gmail.com")
)
assertEquals(
    failure(NonEmptyListF.of("First name malformed: kenneth", "Last name malformed: Barclay")),
    Person.create("Barclay", "kenneth", "me@gmail.com")
)

Observe how the third example identifies the malformed first name and the malformed last name.



The code for the Dogs library can be found at:

https://github.com/KenBarclay/TBA


No comments: