Ir al contenido
  1. Posts/

Una Typeclass para Gobernarlas a Todas 🧙🏻‍♂️

·7 mins
Rafael Fernandez
Autor
Rafael Fernandez
Matemáticas, programación y cosas de la vida
Una Typeclass para Gobernarlas a Todas - Este artículo es parte de una serie.
Parte 1: Este artículo

¡Muy buenas! ¿Qué tal? ¿Cómo comienza el mes? Hoy vengo con un tema que llevaba tiempo queriendo escribir.

“El mundo ha cambiado… lo siento en el código, lo veo en los diseños, lo huelo en las implementaciones.”

A lo largo de mi experiencia profesional, he presenciado múltiples intentos de construir sistemas y diseñar patrones de código. Algunos sólidos como el mismísimo Mithril, otros tan frágiles como una telaraña en Moria. Pero entre todas esas arquitecturas, hay una que destaca por su poder, su flexibilidad y, para muchos, por su misterio: las Typeclasses.

Hoy quiero llevarte en un viaje para desentrañar esta poderosa herramienta de la programación funcional. Como el Anillo Único en la forja de Eregion, las Typeclasses tienen el poder de unir múltiples implementaciones bajo una única abstracción, ofreciendo un diseño extensible y elegante… pero solo para aquellos que se atrevan a comprenderlas.

Así que prepara tu bastón de mago, empuña tu espada y sígueme en esta travesía por los oscuros caminos del polimorfismo ad-hoc. Porque hoy te mostraré cómo esta poderosa técnica puede ayudarte a forjar un código más limpio, modular y escalable.

Una abstracción para gobernarlas a todas, una abstracción para encontrarlas,
Una abstracción para atraerlas a todas y en el código unirlas.
🧙‍♂️✨

una-typeclass-para-gobernarlas-a-todas-img-190.jpg

Modelos de abstracción
#

Para empezar este post, me gustaría plantear previamente un problema, uno muy conocido por muchos: el de hacer login. Todos sabemos lo que es; alguna vez hemos tenido que iniciar sesión en alguna aplicación o plataforma y todos, o la gran mayoría, conocemos las múltiples formas que existen para hacerlo: usuario-contraseña, token (API Key, JWT, etc.), MagicLink, etc.

La idea es poder implementar esa funcionalidad de la manera más genérica, abstrayéndonos lo máximo posible de toda lógica particular y haciéndolo de forma funcional.

Enfoque: Sobrecargar funciones
#

Vamos paso a paso. Una primera aproximación, la más evidente y trivial, sería definir funciones independientes, una para cada escenario:

// User-Password  
def login(user: String, password: String): Result[Error, AuthResponse] = ???  
  
// Token  
def login(token: String): Result[Error, AuthResponse] = ???  

A simple vista esto parece correcto, pero ¿qué ocurre cuando queremos, por ejemplo, añadir una función con la misma sintaxis que la de User-Password? Como, por ejemplo:

// Magic Link  
def login(email: String, link: String): Result[Error, AuthResponse] = ???

El compilador no nos va a permitir continuar, nos mostrará un error similar al siguiente:

[error] /../../Playground0.scala:14:7:
[error]   the conflicting method login was defined at line 8:7
[error]   def login(email: String, link: String): Result[Error, AuthResponse]

Las funciones sobrecargadas tienden a estar acopladas directamente a tipos concretos. Si queremos agregar un nuevo tipo de autenticación (como Magic Link), necesitamos, o bien modificar alguna de las funciones existentes, o crear una nueva con parámetros de entrada que no coincidan con ninguna implementación anterior.

Por otro lado, aunque no se da en este caso, cada implementación podría tener un tipo de resultado distinto, lo que complicaría su uso en flujos genéricos.

// User-Password  
def login(user: String, password: String): Result[Error, UserPassAuthResponse] = ???  
  
// Token  
def login(token: String): Either[String, (TokenAuthResponse, User, Boolean)] = ???

Enfoque: Sobrecargar funciones con newtypes
#

La verdad es que, con el uso de los newtypes, el escenario sería distinto. Si definiéramos los ADTs correspondientes a cada forma de autenticación, podríamos resolver el primer problema en gran parte:

// User-Password  
def login(user: User, password: Password): Result[Error, AuthResponse] = ???  
  
// Token  
def login(token: Token): Result[Error, AuthResponse] = ???  
  
// Magic Link  
def login(email: Email, link: MagicLink): Result[Error, AuthResponse] = ???

Sin embargo, aunque este planteamiento podría ser suficiente, seguiríamos teniendo cierta libertad para que futuras implementaciones se vieran desvirtuadas, deformadas e incluso corrompidas, derivando en cambios potencialmente perniciosos y con consecuencias desastrosas. ¿Alarmante, verdad?

Y realmente, debemos ser así de consecuentes con los resultados, por lo que no estaría de más imponer ciertas restricciones en la implementación de nuestro código.

¿Os suena el principio SOLID? ¿Qué es la Inversión de Dependencias? Exacto: el flujo de control de un programa no debería depender directamente de implementaciones concretas, sino de interfaces o abstracciones.

Enfoque: Función polimórfica
#

Por esta razón, debemos unificar todo un conjunto de operaciones bajo un mismo contrato o sintaxis que nos permita evitar el acoplamiento de la lógica de alto nivel con la lógica de bajo nivel. Más adelante entraré en detalle sobre a qué me refiero con esto último.

El primer patrón que conocemos para la abstracción es el polimorfismo. Definir una función polimórfica es uno de los mecanismos más comunes en la programación funcional. Sin embargo, contemplar y abarcar un comportamiento de manera genérica no siempre es trivial.

una-typeclass-para-gobernarlas-a-todas-img-191.jpg

De hecho, para este caso, debemos hacernos la siguiente pregunta: Para toda forma de autenticación, ¿qué debe hacer nuestro código para que consigamos autenticarnos?

def login[A <: Auth](auth: A): Result[Error, AuthResponse] = ???

Yo diría que, en este caso, es prácticamente imposible pensar en una implementación que pueda ser válida para A.

Enfoque: Función intérprete y pattern matching
#

Otro de los mecanismos más conocidos en programación funcional es el pattern matching. Para este caso, podríamos crear una única función evaluadora o intérprete que se encargue de recorrer cada caso (mediante pattern matching).

una-typeclass-para-gobernarlas-a-todas-img-192.jpg

En este enfoque se define una estructura de datos (ADT) que representa cada tipo de autenticación, y luego se usa pattern matching para manejar cada caso.

sealed trait Auth  
case class UsernamePassword(username: String, password: String) extends Auth  
case class TokenAuth(token: String)                             extends Auth  
case class MagicLinkAuth(email: String, link: String)           extends Auth  
  
def login(auth: Auth): Result[Error, AuthResponse] = auth match {  
  case UsernamePassword(username, password) => ???  
  case TokenAuth(token)                     => ???  
  case MagicLinkAuth(email, link)           => ???  
  case _                                    => ??? // What to do?  
}

Bueno, esto ya tiene mejor pinta. El uso de pattern matching es muy útil en la mayoría de los casos. Digo la mayoría porque, a pesar de ser una herramienta muy potente, tiene un pequeño defecto: la escalabilidad.

Problemas de extensión
#

Por un lado, el ADT es cerrado (sealed), lo que nos obliga a definir todos los posibles casos de autenticación en el mismo objeto. Lo positivo de esto es que el compilador nos advertirá si olvidamos cubrir algún caso. Lo negativo es que no permite que la definición de nuevos casos se realice en otros módulos o dependencias, lo que reduce su extensibilidad.

Además, supongamos que en nuestro equipo de desarrollo se implementan los casos de UsernamePassword y TokenAuth. Pasan diez años, ese código se convierte en código productivo, legacy y estable (lo cual preferiríamos no tocar); y entonces nos piden implementar la nueva forma MagicLinkAuth. Esto implica que tendremos que modificar todas las funciones que utilicen pattern matching, no solo nuestra función intérprete, sino también cualquier otra función que use este enfoque dentro de un océano de código productivo, legacy, estable, (¿y spaghetti?), etc.

Da miedo.

Errores en runtime
#

Por otro lado, si evitamos marcar el ADT como sealed para permitir la extensión, corremos el riesgo de omitir algún caso, provocando un potencial error en tiempo de ejecución.

Obviamente, esto se puede mitigar mediante el uso del wildcard (_) como último caso. Sin embargo, esto nos plantea una nueva pregunta: ¿Cómo manejamos el resto de casos? ¿Lanzamos una excepción? ¿Manejamos el error? No podemos hacer una implementación genérica, pues como mencioné antes, no existe o es prácticamente imposible.

¿Existe alternativa?
#

En cualquiera de las decisiones que tomemos, los posibles errores futuros los obtendremos en runtime. Y aquí viene la pregunta clave: ¿Existe alguna abstracción funcional que abarque todos los casos, que nos dé errores en tiempo de compilación y que, además, sea extensible?

¿Podemos hacer que el agua del mar sea dulce? Pues sí (lo primero, lo segundo creo que no…), pero esto lo veremos en el siguiente post.

Conclusión
#

En este post hemos explorado distintos enfoques para implementar una funcionalidad de autenticación de forma flexible y mantenible. Desde funciones sobrecargadas hasta pattern matching, cada técnica plantea ventajas y desafíos: el acoplamiento excesivo, la falta de escalabilidad o la dificultad de generar una solución verdaderamente genérica.

Lo esencial es encontrar el equilibrio entre flexibilidad y control, aplicando principios como SOLID para construir sistemas más seguros y extensibles. En el próximo post veremos una alternativa que aborda estos desafíos, ofreciendo una solución más limpia y robusta: las typeclasses 🚀

Una Typeclass para Gobernarlas a Todas - Este artículo es parte de una serie.
Parte 1: Este artículo