El BCE asegura en su Web que “el efectivo seguirá siendo el principal medio de pago en el futuro próximo”.1 Además, deja entrever que a pesar de que existen formas alternativas de retirar efectivo como los servicios de cashback y cash-in-shop, los cajeros automáticos seguirán siendo muy importantes para cubrir esta necesidad.

Por lo visto, seguiremos teniendo cajeros automáticos y con ellos la posibilidad de que el cajero no disponga de efectivo suficiente para retirar una cantidad de dinero específica. Por ejemplo, supongamos que queremos retirar 30 €, pero el cajero sólo dispone de billetes de 20 €. Esto es lo que se conoce como un fallo y es un tipo de error esperado, que no es irrecuperable. Cuando ocurre un fallo, el programa puede continuar y proponer una salida alternativa. En el caso de un cajero automático cuya función es proporcionar efectivo, cabe esperar que algunas denominaciones se agoten antes que otras. Al ocurrir este fallo, el cajero podría mostrar un mensaje de error acompañado de acciones que el usuario podría llegar a realizar como retirar una cantidad que sí pueda ser abonada (20 € + 20 € en el ejemplo anterior).

En contraste, existe otro tipo de error llamado error fatal que es irrecuperable, lo que significa que el programa o sistema no puede seguir funcionando.

Manejo de Errores en Cats Effect#

Cats Effect proporciona el tipo IO que permite ejecutar código que produce side effects dentro de un contexto aislado, como si fueran valores “puros”. Se conoce como side effect a aquellos cambios que ocurren fuera del contexto local de una función (como por ejemplo, escribir a una base de datos) o cuando su salida no es determinista (generar un número aleatorio). Generalmente, este tipo de cambios suelen ser más difíciles de implementar porque pueden causar errores inesperados. El tipo IO de Cats Effect facilita el trabajo con código que produce side effects, haciendo que sea más fácil de probar, entender y verificar.

Por ejemplo, la siguiente definición utiliza el tipo IO para englobar las llamadas a las diferentes partes del programa necesarias para retirar efectivo de un cajero automático:

import cats.effect.IO

trait Atm:
  def withdraw(amount: Int): IO[Map[Int, Int]]

Algunas de estas llamadas podrían ser:

  • Comprobar la disponibilidad de las distintas denominaciones (por ejemplo, 100 billetes de 20 €, 50 billetes de 50 €, 10 billetes de 100 €, etc.)
  • Seleccionar la cantidad de billetes de cada denominación para llegar a la cantidad que se quiere retirar.
  • Actualizar la disponibilidad después de retirar el efectivo.

Es durante la selección de los billetes de cada denominación que puede ocurrir un fallo cuando el cajero no dispone de efectivo suficiente para realizar la retirada. A pesar de que este es un fallo esperado, el tipo de la función withdraw de la definición anterior no avisa sobre el mismo. Esto pone de manifiesto una de las limitaciones del tipo IO de Cats Effect que sólo permite generar errores que sean subtipos de Throwable. Por esta razón, inspeccionando el tipo de la función withdraw se puede saber que puede devolver un Map[Int, Int] o puede producir un error de tipo Throwable, pero no podemos saber exactamente cuál es el tipo específico del error que produce y tampoco podemos prepararnos para gestionarlo de la mejor manera posible.

Existen varias estrategias para superar esta limitación.

Either anidado dentro de IO#

Si definimos el tipo AtmError para gestionar los fallos del cajero, podemos hacer que uno de sus subtipos sirva para avisar de que el cajero no dispone de efectivo suficiente para retirar la cantidad solicitada. El tipo OutOfMoney cumple con esta función, y se define utilizando la clase RuntimeException del mecanismo de gestión de excepciones heredado de Java, mezclada con el trait NoStackTrace de Scala que suprime este tipo de error de la traza de errores:

import scala.util.control.NoStackTrace

enum AtmError(val message: String) extends RuntimeException(message) 
    with NoStackTrace:
    case OutOfMoney extends AtmError("Not enough money")

De esta forma, estamos diciendo que este error es un fallo para el que el programa va a proponer una salida alternativa.

La primera estrategia será propagar los errores esperados dentro de un Either anidado dentro de IO quedando el tipo: IO[Either[Throwable, A]]. Después de aplicar este cambio, la definición del cajero queda así:

import cats.effect.IO

trait Atm:
  def withdraw(amount: Int): IO[Either[AtmError, Map[Int, Int]]]

No es el código más ergonómico del mundo, pero al menos deja claro que la función withdraw puede terminar correctamente, devolviendo un Map[Int, Int] o un error de tipo AtmError, o se puede romper produciendo un error de tipo Throwable.

Transformador de mónada EitherT#

El problema principal de la estrategia anterior es el engorro que supone trabajar con dos niveles de anidación: IO y Either. Aunque al principio pueda parecer una estrategia viable, cuando intentamos componer una función de tipo IO[Either[Throwable, A]] con otra IO, se producen situaciones en las que complejidad y la verbosidad del código resultante escala rápidamente como describe este artículo.

Una alternativa consiste en utilizar el tipo EitherT de Cats. EitherT es lo que se conoce como un transformador de mónadas, debido a su capacidad para combinarse con otras mónadas como IO y proporcionar un mecanismo que facilita la composición de funciones con el mismo tipo.

Usando este transformador, el tipo equivalente a IO[Either[Throwable, A]] sería: EitherT[IO, Throwable, A]. Después de aplicar este cambio, la definición del cajero queda así:

import cats.data.EitherT
import cats.effect.IO

trait Atm:
  def withdraw(amount: Int): EitherT[IO, AtmError, Map[Int, Int]]

Aunque aparentemente el cambio es pequeño, en la práctica estamos ganando la posibilidad de operar directamente sobre la parte derecha del Either (el valor “bueno”) sin necesidad de desenvolver la IO y luego el Either, como tendríamos que hacer de aplicar la estrategia anterior.

Cabe la pena recordar que en Cats y en Scala las mónadas son right-biased, y que están diseñadas para operar sobre el valor de la derecha, que representa el valor deseado de la computación. En cambio, el valor de la izquierda representa un error y siempre que una operación monádica como map o flatMap se encuentra un valor izquierdo se cortocircuita y propaga el error sin modificar hasta el final de la cadena de operaciones monádicas. De ahí que coloquialmente se ha descrito este este enfoque como railway oriented programming.

De cualquier forma, trabajar con transformadores de mónadas es bastante difícil, lo que ha hecho que muchos desarrolladores se hayan apartado de este camino, optando por nuevos mecanismos que permitan manipular de manera más ergonómica los tipos de efectos componibles.

Scoped Error Capabilities#

Hace unas semanas fue publicada la versión 1.6.0 de Cats MTL, que incluye una nueva sintaxis para gestionar tipos de errores definidos por el usuario en el ecosistema de Typelevel, al cual pertenecen también Cats y Cats Effect. Esta nueva forma de gestionar errores es parte del rediseño de estas librerías para adaptarse a un estilo de programación más directo.

A diferencia de las anteriores, esta estrategia no cambia el tipo de la función withdraw, sino que añade una nueva capability a la misma. Las capabilities están ligadas al modelo OCAP, un concepto que ha estado presente en los lenguajes de programación desde hace varias décadas, pero no ha sido hasta hace poco que ha adquirido relevancia en Scala por su capacidad para combinar la programación funcional y la imperativa dando como resultado un estilo de programación menos dependiente de las operaciones monádicas, y por tanto más directo.

De manera resumida, una capability describe un derecho que puede ser transferido a un objeto, habilitándolo para realizar una (o más) operaciones. En este caso, la capability Raise le transfiere al cajero Atm la habilidad de generar un error adaptado a sus necesidades:

import cats.effect.IO
import cats.mtl.Raise

trait Atm:
  def withdraw(amount: Int)(using Raise[IO, AtmError]): IO[Map[Int, Int]]

El tipo Raise[F, E] de Cats MTL expresa la habilidad de generar errores de tipo E en un contexto funcional F[_]. Esto lo hace adecuado no sólo para IO, sino también para Future y en general para cualquier tipo que exprese un marcador de posición para la computación asíncrona. Además, la posibilidad de definir el tipo de error aprovecha el type safety y el type soundness de Scala para prevenir errores. De esta forma, el compilador de Scala garantiza que la función withdraw sólo pueda ser llamada dentro de un bloque de código Handle, y que además, se tengan en cuenta todos los subtipos del error AtmError:

import cats.mtl.Handle

Handle
  .allow[AtmError]:
    atm.withdraw(amount)
  .rescue:
    case AtmError.OutOfMoney => ??? // handle the error here

Este diseño no sólo hace explícito los diferentes fallos que pueden ocurrir al llamar la función, sino que además obliga a escribir un código específico para gestionarlos.

Un detalle de la implementación de Cats MTL es que las capabilities disponibles se expresan dentro de un ámbito determinado utilizando evidencia implícita. Es por esto que en el ejemplo anterior, la capability Raise se le pasa a la función withdraw dentro de una using clause. Esta es una solución de conveniencia que aprovecha las características de Scala para conseguir un diseño muy ergonómico.

Vale la pena mencionar que Cats MTL no es el único proyecto que está explorando esta estrategia actualmente. Por ejemplo, raise4s es un intento de adaptar el DSL Raise de la biblioteca Arrow Kt de Kotlin a Scala 3.

Bonus: no hacer nada#

Además de las estrategias mencionadas anteriormente, hay una que consiste en no hacer nada para identificar los fallos y aplicar defensive programming para recuperarnos de los errores.

Podemos interceptar todos los errores del tipo IO de Cats Effect con handleError y handleErrorWith, filtrando aquellos que sean de interés para el programa. Las posibilidades para gestionar errores en Cats Effect son muy diversas, abarcando varios patrones que van desde la recuperación hasta la transformación del error.

Un ejemplo en la vida real#

La kata de programación ATM machine es un ejercicio práctico diseñado para ayudar a los desarrolladores a mejorar sus habilidades mediante la construcción de un cajero automático simulado. Esta kata propone la implementación de funciones básicas de un cajero automático, como retirar efectivo con un suministro limitado. Aunque originalmente fue desarrollada para practicar el TDD, yo la voy a usar para ver qué sensaciones me da el Scoped Error Capabilities.

El problema principal de la retirada de efectivo es seleccionar la cantidad de billetes de cada denominación, de manera tal que se utilice la menor cantidad de billetes. Sin lugar a dudas, este es un problema de optimización.

Al buscar librerías que permitan abordar este tipo de problemas encontré OR-Tools: una suite de software de código abierto para optimización. Esta suite está muy bien documentada a través de una guía que incluye: The Bin Packing Problem.

Con total seguridad, este es un trabajo para la AI, así que lo primero fue pedirle a GitHub Copilot que elaborara una solución usando OR-Tools y ya que estamos que lo hiciera en Java para entenderlo yo mejor. Este fue el prompt que usé:

Given an array of coin denominations and a target amount,
find the minimum number of coins needed to make up that amount.
If the amount cannot be made up by any combination of the coins, return -1.
Assume a finite and known availability of each coin denomination.
Propose the solution using the Google OR-Tools library for Java.

La respuesta de Copilot:

To solve this with Google OR-Tools in Java, 
model it as an integer programming problem:

Variables: number of coins used for each denomination (bounded by availability).
Objective: minimize the total number of coins.
Constraint: sum of (coin value * number used) == target amount.

Here's a concise example using OR-Tools' MPSolver:
import com.google.ortools.Loader;
import com.google.ortools.linearsolver.MPSolver;
import com.google.ortools.linearsolver.MPVariable;
import com.google.ortools.linearsolver.MPObjective;
import com.google.ortools.linearsolver.MPConstraint;

public class MinCoinsSolver {
    public static int minCoins(int[] coins, int[] availability, int amount) {
        Loader.loadNativeLibraries();
        MPSolver solver = MPSolver.createSolver("SCIP");
        if (solver == null) return -1;

        int n = coins.length;
        MPVariable[] x = new MPVariable[n];
        for (int i = 0; i < n; i++) {
            x[i] = solver.makeIntVar(0, availability[i], "x_" + i);
        }

        // Constraint: sum of (coin value * number used) == amount
        MPConstraint ct = solver.makeConstraint(amount, amount, "amount");
        for (int i = 0; i < n; i++) {
            ct.setCoefficient(x[i], coins[i]);
        }

        // Objective: minimize total coins used
        MPObjective obj = solver.objective();
        for (int i = 0; i < n; i++) {
            obj.setCoefficient(x[i], 1);
        }
        obj.setMinimization();

        MPSolver.ResultStatus resultStatus = solver.solve();
        if (resultStatus == MPSolver.ResultStatus.OPTIMAL) {
            return (int) obj.value();
        } else {
            return -1;
        }
    }
}
This function returns the minimum number of coins or -1 if not possible.

Como era de esperar, el código funcionó a la primera para todos los ejemplos de la kata, así que sólo tuve que adaptarlo a Scala (cosa que también le pedí a la AI) y finalmente pude utilizarlo para probar la gestión de errores usando capabilities.

Raise-Handle#

Lo primero es separar las diferentes responsabilidades en componentes independientes que se puedan probar de manera aislada, con lo cual la definición del cajero queda así:

trait Atm:
  def withdraw(cash: Cash)(using Raise[IO, AtmError]): IO[Unit]

Con la siguiente implementación donde se han inyectado los diferentes colaboradores en el constructor de la clase:

class Atm(
  cashRepository: CashRepository,
  cashDispenser: CashDispenser,
  cashPrinter: CashPrinter
):
  override def withdraw(cash: Cash)(using Raise[IO, AtmError]): IO[Unit] =
    for 
      availability <- cashRepository.availabilityIn(cash.currency)
      amounts <- makeAmounts(cash.amount, availability, cashDispenser)
      _ <- cashRepository.remove(cash.currency, amounts)
      _ <- print(amounts, cashPrinter)
    yield ()

El componente CashDispenser se ocupa de seleccionar la cantidad de billetes de cada denominación:

trait CashDispenser:
  def minimumUnits(
      amount: Quantity,
      availabilities: Map[Denomination, Availability],
  )(using Raise[IO, CashDispenserError]): IO[Map[Denomination, Quantity]]

Al extraer esta responsabilidad a otro componente, también hemos ampliado la cadena de errores. Ahora es CashDispenser el que puede generar un error de tipo CashDispenserError para avisar de que no ha podido encontrar una forma de llegar a la cantidad solicitada con la disponibilidad actual de efectivo. Este error se propaga al cajero, que lo transforma en un nuevo tipo de error antes de propagarlo al siguiente nivel:

private def makeAmounts(
    amount: Quantity,
    availability: Map[Denomination, Availability],
    cashDispenser: CashDispenser,
)(using Raise[IO, AtmError]) =
  Handle
    .allow[CashDispenserError]:
      cashDispenser.minimumUnits(amount, availability)
    .rescue:
      case CashDispenserError.NotSolved =>
        AtmError.OutOfMoney.raise[IO, Map[Denomination, Quantity]]

Esta transformación de errores queda explícitamente definida en los tipos de las funciones involucradas. Los mecanismos de Raise-Handle funcionan de manera independiente a las operaciones monádicas, simplicadas en este ejemplo por el syntactic sugar del for-comprehension. De esta forma, el Raise-Handle iguala en comodidad al try-catch de la programación imperativa, sin renunciar a la seguridad de los tipos de Scala.

Cierre#

Los programas pueden encontrar errores previstos, que son fallos que se anticipan durante el diseño para manejar condiciones inusuales de forma controlada. Los errores previstos se diferencian de los errores inesperados y se gestionan con rutinas de manejo de errores, que aseguran la robustez y continuidad del programa en lugar de un colapso total.

Existen al menos cuatro estrategias para gestionar errores en Cats Effect:

  • Either anidado dentro de IO: Usando IO[Either[E, A]] para hacer explícitos los errores de tipo E esperados, pero esto conduce a tipos complejos y anidados.
  • Transformador de mónada EitherT: Usando EitherT[IO, E, A] para una composición más ergonómica y un mejor manejo de errores, pero poco usado por la dificultad que conlleva trabajar con tranformadores de mónada.
  • Scoped Error Capabilities: Usar capacidades como Raise[IO, E] y Handle[IO, E] para gestionar errores definidos por el usuario de una manera más directa y segura, sin cambiar las firmas de las funciones.
  • ApplicativeError y MonadError: Usando los mecanismos de gestión de errores como handleError y handleErrorWith proporcionados por los tipos ApplicativeError[IO, E] y MonadError[IO, E], que sirven para cualquier subtipo de Throwable.

Scoped Error Capabilities, a pesar de ser muy reciente, destaca por sus ventajas para el manejo de errores en la programación funcional. Este patrón resultó muy útil para solucionar la kata ATM machine, con ayuda de la librería OR-Tools para la optimización de las distintas denominaciones.

El código completo del ejemplo está disponible en GitHub2.

OR-Tools