Kotlin Arrow: Mastering navigation in the Either monad
Published on 24 September 2024
- Introduction
- What is the Either monad?
- Context
- The different approaches
- The classic approach with `when
- Use of
foldfor a more concise approach - Transformation with
mapand `mapLeft - Error handling with `getOrElse
- Side actions with
onLeftand `onRight - Chaining operations with `flatMap
- Bidirectional transformation with `bimap
- Swapping the sides with `swap
- Usage of
tapandtapLeft: - Use of
recover:
- practical use cases
- Conclusion
- To go further
Introduction
In the world of functional programming in Kotlin, the Arrow library provides powerful tools for handling errors and alternative cases. Among these tools, the monad`Either`stands out for its ability to represent two possible states: success (Right) or failure (Left). But how to navigate effectively between these two states? That’s 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, with Arrow, it is often used to handle the success and failure cases of an operation, providing an elegant alternative to traditional exceptions.
Why use Either ?
-
Explicit error handling: Forces the developer to consider failure cases.
-
Functional composition: Facilitates the chaining of operations that can fail.
-
Type-safety: Errors are typed, which helps handle them more precisely.
-
No exceptions: Avoid side effects and unexpected interruptions of the execution flow.
Context
Let’s 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`who returns a`Either<Throwable, User>`How can you process this result in an elegant and functional way?
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 with `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 fully take advantage of Arrow’s functional capabilities.
Use of fold for a more concise approach
result.fold(
{ error -> println("Erreur : ${error.message}") },
{ user -> println("Utilisateur trouvé : ${user.login}") }
)
`fold`allows 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 transforming the values contained in Either while preserving its structure, ideal for more complex processing chains.
Error handling with `getOrElse
val user = result.getOrElse { error ->
println("Erreur : ${error.message}")
User(login = "default", email = "[email protected]") // 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 performing 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("[email protected]")
.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 to transform both the left side and the right side in a single operation.
Swapping the 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 a function to another.
Usage of 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.
Use of recover :
val recoveredUser = result.recover { error ->
println("Erreur récupérée : ${error.message}")
User(login = "recovered", email = "[email protected]")
}
println("Login : ${recoveredUser.login}")
This method allows converting an Either.Left into an Either.Right by providing a replacement value.
practical use cases
-
Use`fold`for simple operations requiring processing for each case. - Préférez
mapetmapLeft`for data transformations without changing the structure of l'`Either. Opt for`flatMap`when chaining operations that can fail Employ`recover`to provide a default value in case of error. - Choose`onLeft` etonRight(outapettapLeft) for side effects like 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 according to the context of use. Methods like`fold`, map/mapLeft, et `recover`are particularly useful when you want to chain several operations or transform the data functionally.
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 code that is more robust, more readable, and more functional.
In your next project, don’t hesitate 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/[Flèche soit] - Kotlin Coroutines with Arrow:https://arrow-kt.io/docs/fx/[Arrow Fx Coroutines]
Don’t forget to share your experiences and your favorite techniques for working with`Either`in the comments below!