Arrow en Kotlin: programación funcional con Either, Raise y coroutines

Kotlin ya tiene bastante de serie para programar de forma funcional: lambdas, funciones de extensión, sealed classes, null safety. Pero cuando la lógica de dominio se complica, el manejo de errores se convierte en un problema. Las excepciones son invisibles en los tipos de retorno y los try-catch anidados se vuelven rápidamente un laberinto.

Arrow resuelve eso. Es una librería que lleva a Kotlin los patrones de la programación funcional tipada sin abandonar la sintaxis del lenguaje. Arrow 2.x, publicada en 2024, ha limpiado la API y se ha concentrado en lo que más se usa: Either, el DSL Raise, Resource y herramientas para resiliencia.

Qué añade Arrow a Kotlin

Arrow no pretende reinventar Kotlin. Lo que hace es completar lo que ya está:

  • Tipos funcionales: Either para errores tipados, Option para valores que pueden no existir.
  • El DSL Raise: un bloque de contexto para encadenar operaciones que pueden fallar sin anidar when.
  • Resource: gestión segura de recursos con cierre garantizado.
  • arrow-resilience: políticas de reintento y circuit breaker.

La integración con coroutines de Kotlin es nativa: todo funciona con suspend sin adaptadores ni conversiones.

Either<Error, Value>: errores visibles en el tipo de retorno

Either tiene dos casos: Left lleva el error y Right lleva el valor cuando todo va bien. La gracia es que el error deja de ser invisible. Si una función devuelve Either<UserError, User>, sabes de entrada que puede fallar y con qué tipo de error.

sealed class UserError {
    object NotFound : UserError()
    object Unauthorized : UserError()
}

fun findUser(id: Int): Either<UserError, User> {
    val user = database.findById(id)
    return if (user != null) user.right() else UserError.NotFound.left()
}

Para consumir el resultado usas when:

when (val result = findUser(42)) {
    is Either.Left  -> handleError(result.value)
    is Either.Right -> processUser(result.value)
}

Comparado con las excepciones, la diferencia es clara: el compilador te obliga a manejar ambos casos. No puedes olvidarte del error porque está en el tipo.

El DSL Raise: encadenar pasos sin anidar

El problema de Either solo es que, si tienes varios pasos que pueden fallar, acabas con when dentro de when. El DSL either { } resuelve eso con .bind():

suspend fun getOrderForUser(userId: Int): Either<AppError, Order> = either {
    val user  = findUser(userId).bind()
    val order = findOrder(user).bind()
    order
}

Dentro del bloque either { }, cada llamada a .bind() hace lo siguiente: si el Either es Right, extrae el valor y sigue; si es Left, sale del bloque inmediatamente y devuelve ese error. Sin bind(), tendrías que anidar un when por cada paso.

Si conoces Rust, el efecto es el mismo que el operador ?. Si conoces Haskell, es el bind monádico. En Kotlin, simplemente parece código normal.

Raise<E>: el contexto funcional de errores en Arrow 2.x

En Arrow 2.x, Raise<E> es la interfaz que hay detrás del DSL. Define la capacidad de lanzar un error tipado dentro de un contexto funcional. Puedes usarla directamente como receptor de función:

suspend fun Raise<UserError>.findUser(id: Int): User {
    val user = database.findById(id)
    return user ?: raise(UserError.NotFound)
}

raise() no lanza una excepción, interrumpe el cálculo de forma controlada. El error queda reflejado en el tipo. Cuando llamas a esta función dentro de un bloque either { }, el compilador sabe que el contexto Raise está disponible y todo encaja.

Option: para cuando el valor puede no existir

Option<T> tiene dos casos: Some(value) cuando hay valor y None cuando no. Es la alternativa funcional a T?.

val maybeName: Option<String> = Some("Kotlin")
val empty: Option<String> = None

val length = maybeName.map { it.length }  // Some(6)
val nothing = empty.map { it.length }     // None

En Kotlin moderno, T? cubre la mayoría de los casos de uso de Option. La propia documentación de Arrow lo reconoce: usa T? para nullable simple. Option tiene sentido cuando necesitas composición funcional compleja o cuando trabajas en un contexto donde null tiene otro significado.

Resource: cierres garantizados

Resource sirve para gestionar recursos que hay que cerrar siempre, haya error o no. Es similar a use { } de Kotlin pero compone mejor y es consciente de coroutines:

val connection: Resource<Connection> = Resource(
    acquire = { openDatabaseConnection() },
    release = { conn, _ -> conn.close() }
)

connection.use { conn ->
    // usa la conexión
    // close() se llama siempre al salir, incluso si hay excepción
}

Para ficheros, clientes HTTP o conexiones a bases de datos que deben cerrarse, Resource es más fiable que un bloque try-finally. Y si necesitas combinar varios recursos, puedes componerlos con zip o dentro de un bloque resource { }.

Schedule y resiliencia con arrow-resilience

El módulo arrow-resilience trae políticas de reintento y circuit breaker para operaciones que pueden fallar por causas transitorias como un servicio externo caído o un timeout de red.

Reintento con Schedule

import arrow.resilience.Schedule
import kotlin.time.Duration.Companion.milliseconds

// Reintentar 3 veces con backoff exponencial
val policy = Schedule.recurs<Throwable>(3)
    .zipLeft(Schedule.exponential(100.milliseconds))

policy.retry { callExternalService() }

Circuit Breaker

import arrow.resilience.CircuitBreaker
import kotlin.time.Duration.Companion.minutes

val cb = CircuitBreaker.of(
    maxFailures   = 5,
    resetTimeout  = 1.minutes
)

cb.protectOrThrow { callExternalService() }

Con maxFailures = 5, tras 5 fallos consecutivos el circuit breaker abre y las llamadas siguientes fallan de inmediato sin llegar al servicio. Pasado resetTimeout, prueba de nuevo. Es el patrón estándar para no saturar un servicio que ya está caído.

Cuándo usar Arrow y cuándo no

Arrow no es para todo. Antes de añadirla a un proyecto, vale la pena pensar qué problema estás resolviendo.

Kotlin y el manejo funcional de errores ya ofrece herramientas decentes: sealed classes, Result, null safety. En proyectos pequeños con pocos puntos de fallo, un sealed class Result propio con un par de subclases puede ser suficiente y evita añadir una dependencia externa.

Arrow empieza a brillar cuando:

  • La capa de dominio encadena varios pasos que pueden fallar y quieres que los errores sean legibles en los tipos.
  • Necesitas componer recursos o gestionar reintentos de forma declarativa.
  • Trabajas en un equipo que ya está cómodo con conceptos funcionales.

Lo que sí puedes hacer en cualquier proyecto, grande o pequeño, es usar Either y el DSL raise en la capa de dominio sin tocar el resto. No tienes que adoptar Arrow al completo.

Resumen práctico

  • Either + Raise DSL: sí, para error handling tipado en dominio.
  • Resource: sí, para conexiones y ficheros que deben cerrarse.
  • arrow-resilience: sí, si haces llamadas a servicios externos.
  • Option: solo si T? no te basta por razones de composición.

Kotlin: expresividad funcional en el desarrollo moderno tiene buenas bases para entender por qué el lenguaje encaja bien con estos patrones. Arrow los lleva un paso más allá sin pedirte que cambies el estilo de código al completo.

Imagen: Pexels / Christina Morillo

COMPARTE ESTE ARTÍCULO

COMPARTIR EN FACEBOOK
COMPARTIR EN TWITTER
COMPARTIR EN LINKEDIN
COMPARTIR EN WHATSAPP