Sunday, February 27, 2022

Kotlin: Either type

Kotlin: Either type

Just as its english counterpart describes, Either can represent one value or another. Scenarios where this might be the return value from a function where you may get the successful result value or you might get an error value. The class is included in the custom library Dogs.



The Option type allows us to represent failures and exceptions with ordinary values and provide functions that abstract out common patterns of  error handling and recovery. The functions bind and ap over the Option type did just this. One issue with the Option type is that it does not report what is wrong in an exceptional case. All we have is None, indicating that there is no value that can be delivered.

Either is a container type with two type parameters: An Either<A, B> instance can contain either an instance of A, or an instance of B (a disjoint type)Either has exactly two sub types, Left and Right. If an Either<A, B> object contains an instance of A, then the Either is a Left. Otherwise it contains an instance of B and is a Right. The Either sealed class is:

sealed class Either<out A, out B> {

    class Left<out A, out B>internal constructor(val value: A) : Either<A, B>()

    class Right<out A, out B>internal constructor(val value: B) : Either<A, B>()

}

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

assertEquals(true,      left<String, Int>("ken").isLeft())
assertEquals(false,     right<String, Int>(25).isLeft())

Either is a general purpose type for use whenever you need to deal with a result that can be one of two possible values. Nevertheless, error handling is a popular use case for it, and by convention Left represents the error case and Right the success value:

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

assertEquals(right(123),                                parseInt("123"))
assertEquals(left("parseInt: bad number format: ken"),  parseInt("ken"))



Pattern matching

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

fun <A, B, C, D> bimap(either: Either<A, B>, f: (A) -> C, g: (B) -> D): Either<C, D> =
    when (either) {
        is Either.Left -> left(f(either.value))
        is Either.Right -> right(g(either.value))
    }   // bimap

class DomainError(val text: String?) : Exception(text)

val currentDate: Either<Exception, Calendar> =     // simple definition
    right(GregorianCalendar(2020, 4, 1))

val res: Either<Exception, Long> = bimap(currentDate,
    {ex -> DomainError(ex.message)}, 
    {date -> date.timeInMillis}
)
assertEquals(right(1588287600000),   res)


The function f is applied to the wrapped value if it is a Left instance and the function g is applied to the wrapped value if it is a Right instance. The val binding for currentDate wraps a calendar value in a Right. Applying the bimap function to it we make a DomainError if the first parameter is a Left. If the first parameter is a Right we convert the wrapped calendar to its milliseconds.



Some Either operations

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

fun <C> fold(fa: (A) -> C, fb: (B) -> C): C

and applies function fa if this is a Left or function fb if this is a Right. A consequence is that bimap is a special case of fold as in:

class DomainError(val text: String?) : Exception(text)

val currentDate: Either<Exception, Calendar> =     // simple definition
        right(GregorianCalendar(2020, 4, 1))

val res: Either<Exception, Long> = currentDate.fold(
    {ex -> left(DomainError(ex.message))},
    {date -> right(date.timeInMillis)}
)
assertEquals(right(1588287600000),   res)


The Either type is right-biased, so functions such as map and bind apply only to the Right case. This right-bias makes Either convenient in, for example, a monadic context.

assertEquals(left("Ken"),  left<String, Int>("Ken").map{n -> 2 * n})
assertEquals(right(4),     right<String, Int>(2).map{n -> 2 * n})




Case Study

A Project represents some work hosted on a repository service such as GitHub. Each Project records its URL and the list of contributors. Our purpose is to identify those projects with no contributors and in need of support, and at the same time a list of all contributors. Here is how we process our project list:

class Project(val url: URL, val contributors: List<String>)

val git: List<Project> = ListF.of(
    Project(URL("http://github.com/project/ai"),           ListF.of()),
    Project(URL("http://github.com/project/algol"),        ListF.of("EdsgerD", "JohnB", "PeterN", "KenB")),
    Project(URL("http://github.com/project/antlr"),        ListF.of("TerranceP")),
    Project(URL("http://github.com/project/data"),         ListF.of("KenB", "JohnS")),
    Project(URL("http://github.com/project/ml"),           ListF.of()),
    Project(URL("http://github.com/project/system"),       ListF.of("BrianK", "DennisR"))
)

val checked: List<Either<URL, List<String>>> =
    git.map{project ->
        if (project.contributors.isEmpty())
            left<URL, List<String>>(project.url)
        else
            right<URL, List<String>>(project.contributors)
    }

val support: List<Option<URL>> = checked.bind{either -> ListF.singleton(either.fold({ url -> some(url)}, {none()}))}
val supportReq: List<Option<URL>> = support.filter{option: Option<URL> -> (option.isDefined())}
val supportRequired: Option<List<URL>> = supportReq.sequenceOption()

assertEquals(
    ListF.of(URL("http://github.com/project/ai"), URL("http://github.com/project/ml")),
    supportRequired.getOrElse(ListF.empty())
)

val supporters: List<String> = checked.bind{either -> either.fold({ListF.empty<String>()}, {names -> names})}.removeDuplicates()

assertEquals(
    ListF.of("EdsgerD", "JohnB", "PeterN", "KenB", "TerranceP", "JohnS", "BrianK", "DennisR"),
    supporters
)


We create in checked a list of Either values, with the Left instances representing unsupported projects and the Right instances containing the contributors. The sequence of val bindings support is a List of Options wrapping the URL; supportReq is also a List of Options but without any None instances; supportRequired is a List of URLs wrapped in an Option. The val binding supporters is the List of contributors.



Case Study

A user input 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 text fields 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 Either for the error handling. We start with:

typealias Error = String

data class LastName(val lastName: String) {

    companion object {

        fun create(lastName: String): Either<Error, LastName> {
            return if (lastName.length < 2)
                left("Last name too short: $lastName")
            else if (!regex.matches(lastName))
                left("Last name malformed: $lastName")
            else
                right(LastName(lastName))
        }   // create

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

}   // LastName

Typically, Error would be a sealed class with sub-types for the various error types. Also we might make the constructor for LastName private so that users must use function create.

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:

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

    companion object {

        fun create(lastName: String, firstName: String, email: String): Either<Error, Person> {
            val eLastName: Either<Error, LastName> = LastName.create(lastName)
            val eFirstName: Either<Error, FirstName> = FirstName.create(firstName)
            val eEmail: Either<Error, Email> = Email.create(email)

            return eLastName.bind{lastName ->
                eFirstName.bind{firstName ->
                    eEmail.bind{email ->
                        inject(Person(lastName, firstName, email))
                    }
                }
            }
        }   // create

    }

}   // Person

We know that the monad fails fast and so can only identify the first error. We see this in the second and third assert:

assertEquals(
    right(Person(LastName("BARCLAY"), FirstName("Ken"), Email("me@gmail.com"))),
    Person.create("BARCLAY", "Ken", "me@gmail.com")
)
assertEquals(
    left("First name malformed: kenneth"),
    Person.create("BARCLAY", "kenneth", "me@gmail.com")
)
assertEquals(
    left("Last name malformed: Barclay"),
    Person.create("Barclay", "kenneth", "me@gmail.com")
)



The code for the Dogs library can be found at:

https://github.com/KenBarclay/TBA

No comments: