Reading time: 12 to 15 minutes.

Target audience: Intermediate to advanced Kotlin developers.

Introduction

In the world of functional programming in Kotlin, the Arrow library offers powerful tools for managing errors and alternative cases. Among these tools, the monad`Either`it is distinguished by its ability to represent two possible states: success (Right) or failure (Left). But how can we navigate effectively between these two states? This is what we will explore in this article.

What is the Either monad?

The monad`Either`is a data structure that represents two mutually exclusive possibilities. In Kotlin with Arrow, it is often used to handle the success and failure cases of an operation, offering an elegant alternative to traditional exceptions.

Why use Either?

  1. Explicit error handling: Forces the developer to consider failure cases.

  2. Functional composition: Facilitates the chaining of operations that may fail.

  3. Type-safety : Les erreurs sont typées, ce qui aide à les gérer de manière plus précise.

  4. No exceptions: Avoid side effects and unexpected interruptions to the execution flow.

Context

Imagine that you are developing a REST API withhttps://spring.io/projects/spring-boot/[Spring Boot, windows=read-later] et Kotlin, using the libraryhttps://arrow-kt.io/[Arrow,windows=read-later]You have a function`findOneUserByEmail`which returns a`Either<Throwable, User>`. Comment pouvez-vous traiter ce résultat de manière élégante et fonctionnelle ?

We have a User class:

@file:Suppress(
    "RemoveRedundantQualifierName",
    "MemberVisibilityCanBePrivate",
    "SqlNoDataSourceInspection"
)

package webapp.users

import arrow.core.Either
import arrow.core.left
import arrow.core.right
import com.fasterxml.jackson.annotation.JsonIgnore
import com.fasterxml.jackson.databind.ObjectMapper
import jakarta.validation.constraints.NotNull
import jakarta.validation.constraints.Pattern
import jakarta.validation.constraints.Size
import org.springframework.beans.factory.getBean
import org.springframework.context.ApplicationContext
import org.springframework.data.r2dbc.core.R2dbcEntityTemplate
import org.springframework.r2dbc.core.DatabaseClient
import org.springframework.r2dbc.core.awaitOne
import org.springframework.r2dbc.core.awaitRowsUpdated
import webapp.core.property.ANONYMOUS_USER
import webapp.core.property.EMPTY_STRING
import webapp.core.utils.AppUtils.cleanField
import webapp.users.EntityModel.Companion.ID_MEMBER
import webapp.users.User.UserDao.Attributes.EMAIL_ATTR
import webapp.users.User.UserDao.Attributes.ID_ATTR
import webapp.users.User.UserDao.Attributes.LANG_KEY_ATTR
import webapp.users.User.UserDao.Attributes.LOGIN_ATTR
import webapp.users.User.UserDao.Attributes.PASSWORD_ATTR
import webapp.users.User.UserDao.Attributes.VERSION_ATTR
import webapp.users.User.UserDao.Constraints.LOGIN_REGEX
import webapp.users.User.UserDao.Fields.EMAIL_FIELD
import webapp.users.User.UserDao.Fields.ID_FIELD
import webapp.users.User.UserDao.Fields.LANG_KEY_FIELD
import webapp.users.User.UserDao.Fields.LOGIN_FIELD
import webapp.users.User.UserDao.Fields.PASSWORD_FIELD
import webapp.users.User.UserDao.Fields.VERSION_FIELD
import webapp.users.User.UserDao.Relations.INSERT
import webapp.users.security.Role
import webapp.users.security.Role.RoleDao
import webapp.users.security.UserRole.UserRoleDao
import java.util.*
import jakarta.validation.constraints.Email as EmailConstraint

data class User(
    override val id: UUID? = null,

    @field:NotNull
    @field:Pattern(regexp = LOGIN_REGEX)
    @field:Size(min = 1, max = 50)
    val login: String,

    @JsonIgnore
    @field:NotNull
    @field:Size(min = 60, max = 60)
    val password: String = EMPTY_STRING,

    @field:EmailConstraint
    @field:Size(min = 5, max = 254)
    val email: String = EMPTY_STRING,

    @JsonIgnore
    val roles: MutableSet<Role> = mutableSetOf(Role(ANONYMOUS_USER)),

    @field:Size(min = 2, max = 10)
    val langKey: String = EMPTY_STRING,

    @JsonIgnore
    val version: Long = -1,
) : EntityModel<UUID>() {

    companion object {
        @JvmStatic
        fun main(args: Array<String>) = println(UserDao.Relations.sqlScript)
    }

    object UserDao {
        object Constraints {
            // Regex for acceptable logins
            const val LOGIN_REGEX =
                "^(?>[a-zA-Z0-9!$&*+=?^_`{|}~.-]+@[a-zA-Z0-9-]+(?:\\.[a-zA-Z0-9-]+)*)|(?>[_.@A-Za-z0-9-]+)$"
            const val PASSWORD_MIN: Int = 4
            const val PASSWORD_MAX: Int = 16
            const val IMAGE_URL_DEFAULT = "https://placehold.it/50x50"
            const val PHONE_REGEX = "^(\\+|00)?[1-9]\\d{0,49}\$"
        }

        object Members {
            const val PASSWORD_MEMBER = "password"
            const val ROLES_MEMBER = "roles"
        }

        object Fields {
            const val ID_FIELD = "`id`"
            const val LOGIN_FIELD = "`login`"
            const val PASSWORD_FIELD = "`password`"
            const val EMAIL_FIELD = "`email`"
            const val LANG_KEY_FIELD = "`lang_key`"
            const val VERSION_FIELD = "`version`"
        }

        object Attributes {
            val ID_ATTR = ID_FIELD.cleanField()
            val LOGIN_ATTR = LOGIN_FIELD.cleanField()
            val PASSWORD_ATTR = PASSWORD_FIELD.cleanField()
            val EMAIL_ATTR = EMAIL_FIELD.cleanField()
            const val LANG_KEY_ATTR = "langKey"
            val VERSION_ATTR = VERSION_FIELD.cleanField()
        }

        object Relations {
            const val TABLE_NAME = "`user`"
            const val SQL_SCRIPT = """
            CREATE TABLE IF NOT EXISTS $TABLE_NAME (
                $ID_FIELD                     UUID default random_uuid() PRIMARY KEY,
                $LOGIN_FIELD                  VARCHAR,
                $PASSWORD_FIELD               VARCHAR,
                $EMAIL_FIELD                  VARCHAR,
                $LANG_KEY_FIELD               VARCHAR,
                $VERSION_FIELD                bigint
            );
            CREATE UNIQUE INDEX IF NOT EXISTS `uniq_idx_user_login`
            ON $TABLE_NAME ($LOGIN_FIELD);
            CREATE UNIQUE INDEX IF NOT EXISTS `uniq_idx_user_email`
            ON $TABLE_NAME ($EMAIL_FIELD);
"""

            @Suppress("SqlDialectInspection")
            const val INSERT = """
            insert into $TABLE_NAME (
                $LOGIN_FIELD, $EMAIL_FIELD,
                $PASSWORD_FIELD, $LANG_KEY_FIELD,
                $VERSION_FIELD
            ) values ( :login, :email, :password, :langKey, :version)"""

            @JvmStatic
            val sqlScript: String
                get() = setOf(
                    UserDao.Relations.SQL_SCRIPT,
                    RoleDao.Relations.SQL_SCRIPT,
                    UserRoleDao.Relations.SQL_SCRIPT
                ).joinToString("")
                    .trimMargin()
        }

        object Dao {
            val Pair<User, ApplicationContext>.toJson: String
                get() = second.getBean<ObjectMapper>().writeValueAsString(first)

            suspend fun Pair<User, ApplicationContext>.save(): Either<Throwable, Long> = try {
                second.getBean<R2dbcEntityTemplate>()
                    .databaseClient
                    .sql(INSERT)
                    .bind(LOGIN_ATTR, first.login)
                    .bind(EMAIL_ATTR, first.email)
                    .bind(PASSWORD_ATTR, first.password)
                    .bind(LANG_KEY_ATTR, first.langKey)
                    .bind(VERSION_ATTR, first.version)
                    .fetch()
                    .awaitRowsUpdated()
                    .right()
            } catch (e: Throwable) {
                e.left()
            }


            suspend fun ApplicationContext.findOneUserByEmail(
                email: String
            ): Either<Throwable, User> = try {
                getBean<DatabaseClient>()
                    .sql("SELECT * FROM `user` WHERE LOWER(email) = LOWER(:email)")
                    .bind("email", email)
                    .fetch()
                    .awaitOne()
                    .let { row ->
                        User(
                            id = row[ID_ATTR] as UUID?,
                            login = row[LOGIN_ATTR] as String,
                            password = row[PASSWORD_ATTR] as String,
                            email = row[EMAIL_ATTR] as String,
                            langKey = row[LANG_KEY_ATTR] as String,
                            version = row[VERSION_ATTR] as Long
                        )
                    }.right()
            } catch (e: Throwable) {
                e.left()
            }
        }
    }

    /** Account REST API URIs */
    object UserRestApis {
        const val API_AUTHORITY = "/api/authorities"
        const val API_USERS = "/api/users"
        const val API_SIGNUP = "/signup"
        const val API_SIGNUP_PATH = "$API_USERS$API_SIGNUP"
        const val API_ACTIVATE = "/activate"
        const val API_ACTIVATE_PATH = "$API_USERS$API_ACTIVATE?key="
        const val API_ACTIVATE_PARAM = "{activationKey}"
        const val API_ACTIVATE_KEY = "key"
        const val API_RESET_INIT = "/reset-password/init"
        const val API_RESET_FINISH = "/reset-password/finish"
        const val API_CHANGE = "/change-password"
        const val API_CHANGE_PATH = "$API_USERS$API_CHANGE"
    }
}

// Abstract entity model with Generic ID, which can be of any type
abstract class EntityModel<T>(
    open val id: T? = null
) {
    companion object {
        const val ID_MEMBER = "id"
    }
}

// Generic extension function that allows the ID to be applied to any EntityModel type
inline fun <reified T : EntityModel<ID>, ID> T.withId(id: ID): T {
    // Use reflection to create a copy with the passed ID
    return this::class.constructors.first { it.parameters.any { param -> param.name == ID_MEMBER } }
        .call(id, *this::class.constructors.first().parameters.drop(1).map { param ->
            this::class.members.first { member -> member.name == param.name }.call(this)
        }.toTypedArray())
}

The different approaches

The classic approach using `when

val user: User by lazy { userFactory(USER) }

val result: Either<Throwable, User> = context.findOneUserByEmail(user.email)

when (result) {
    is Either.Left -> {
        val error = result.value
        println("Erreur : ${error.message}")
    }
    is Either.Right -> {
        val user = result.value
        println("Utilisateur trouvé : ${user.login}")
    }
}

This method, while simple and readable, does not take full advantage of Arrow’s functional capabilities.

Using fold for a more concise approach

result.fold(
{ error -> println("Erreur : ${error.message}") },
{ user -> println("Utilisateur trouvé : ${user.login}") }
)

`fold`allows for defining actions for both cases (Left and Right) in a concise and elegant manner.

Transformation with map and `mapLeft

val processedResult = result
    .map { user -> "Utilisateur trouvé : ${user.login}" }
    .mapLeft { error -> "Erreur : ${error.message}" }

println(processedResult.merge())

This approach allows for transforming the values contained within Either while preserving its structure, making it ideal for more complex processing chains.

Error handling with `getOrElse

val user = result.getOrElse { error ->
    println("Erreur : ${error.message}")
    User(login = "default", email = "default@example.com") // utilisateur par défaut
}
println("Login : ${user.login}")

`getOrElse`offers elegant error handling by allowing a default value to be provided.

Side actions with onLeft and `onRight

result.onLeft { error -> println("Erreur : ${error.message}") }
.onRight { user -> println("Utilisateur trouvé : ${user.login}") }

These methods allow you to perform actions on each side without modifying the Either, perfect for logging or light side effects.

Chaining operations with `flatMap

fun findUser(email: String): Either<Throwable, User> = // ... implémentation

fun getUserPermissions(user: User): Either<Throwable, List<String>> = // ... implémentation

val userPermissions = findUser("user@example.com")
    .flatMap { user -> getUserPermissions(user) }

flatMap`is useful for chaining operations that themselves return`Either, thus avoiding the`Either`nested.

Bidirectional transformation with `bimap

val result: Either<Throwable, User> = // ... obtention du résultat
val processedResult = result.bimap(
    { error -> "Erreur: ${error.message}" },
    { user -> "Utilisateur: ${user.login}" }
)

`bimap`allows both the left and right sides to be transformed in a single operation.

Swap sides with `swap

val result: Either<Throwable, User> = // ... obtention du résultat
val swapped = result.swap()

swap`is useful when you want to reverse the sides of a`Either, for example to adapt the interface of one function to another.

Using tap and tapLeft:

result.tap { user -> println("Utilisateur trouvé : ${user.login}") }
.tapLeft { error -> println("Erreur : ${error.message}") }

Similar to`onLeft` et onRight, but these methods return the original Either, which is useful for chaining operations.

Using recover:

val recoveredUser = result.recover { error ->
    println("Erreur récupérée : ${error.message}")
    User(login = "recovered", email = "recovered@example.com")
}
println("Login : ${recoveredUser.login}")

This method allows you to transform an Either.Left into an Either.Right by providing a replacement value.

Practical use cases

  • Use`fold`for simple operations requiring case-by-case processing. - Prefer`map` et mapLeft`for data transformations without changing the structure of the`Either. - Opt for`flatMap`when chaining operations that may fail. - Use`recover`to provide a default value in case of an error. - Choose`onLeft` et onRight (ou tap et tapLeft) for side effects such as logging. - Use`bimap`to transform both sides into a single operation. - Apply`swap`when you need to adapt the interface of one function to another.

Conclusion

Each of these approaches has its advantages depending on the context of use. Methods such as`fold`, map/mapLeft, et `recover`are particularly useful when you want to chain multiple operations or transform data in a functional manner.

The monad`Either`Arrow offers remarkable flexibility for handling success and error cases in your Kotlin applications. By mastering these different approaches, you will be able to write more robust, readable, and functional code.

In your next project, feel free to explore these techniques to get the most out of functional programming with Kotlin and Arrow!

To go further

  • Official Arrow documentation:https://arrow-kt.io/docs/apidocs/arrow-core/arrow.core/-either/[Arrow Either]- Kotlin Coroutines with Arrow:https://arrow-kt.io/docs/fx/[Arrow Fx Coroutines]

Don’t forget to share your experiences and favorite techniques for working with`Either` dans les commentaires ci-dessous !

Related articles