Muy buenas. Continuamos con la serie Una Typeclass para Gobernarlas a Todas. En este post entraremos en detalle. Hablaremos de qué son, cómo definirlas y cómo razonar con ellas.
La abstracción del comportamiento #
Antes de entrar en detalle, hablemos de las typeclasses: ¿Qué son? ¿Para qué se utilizan? ¿Cuál es su finalidad?
Las typeclasses son, simple y llanamente, un patrón de diseño de código. Una estructura o mecanismo que encaja en la programación funcional y que tiene una serie de ventajas. Las ventajas son las que buscábamos en el anterior post: una forma de abstraernos, de manera funcional, que sea escalable y que nos permita dar errores en tiempo de compilación.
Ahora bien, aparte del propósito técnico que estamos buscando, ¿qué sentido tiene? ¿Cuál es su propósito? Una typeclass se utiliza para modelar un comportamiento.
Este concepto es muy importante y vamos a desarrollarlo en este post. Cuando queremos abstraernos sobre una funcionalidad, intentando abarcar el máximo abanico posible de casos, lo que pretendemos es cubrir un comportamiento.
-
En el caso de escribir en una base de datos: crear una entidad en un Postgres, persistir el caché en un Redis, dejar constancia del evento en un Cassandra, etc. En definitiva, queremos el comportamiento de interactuar con un agente de persistencia/repositorio.
-
En el caso de autenticar mediante login: usar el método user-password, mediante token, MagicLink, etc. En definitiva, queremos el comportamiento de autenticarse.
-
En otros casos más abstractos, las mónadas (comportamiento de computación secuencial), los semigrupos (que exista una operación binaria cerrada) y los monoides (lo anterior, además de un elemento neutro. Por ejemplo: las listas con la operación concatenación y la lista vacía).
No es nada trivial definir qué comportamiento es el que vamos a modelar, ni tampoco qué forma debería tener, pero lo que sí tenemos claro es que las typeclasses son el recurso/herramienta/patrón de diseño que nos va a facilitar modelarlo.
Vamos a aterrizar esta idea.
Estructura de una Typeclass #
Dependiendo del lenguaje, una typeclass se implementará de una forma u otra. Como este post es sobre Scala, y más concretamente Scala 2, ese será nuestro desarrollo. Sin embargo, dejaré unos snippet codes con ejemplos de otros lenguajes, como Rust, Haskell o incluso Scala 3.
Definición, Trait o Gramática #
La primera parte con la que nos debemos enfrentar es la de la gramática/sintaxis de nuestra typeclass. Esto es, el contrato que se va a fijar y que va a determinar, primeramente, la semántica denotacional (el significado del comportamiento, es decir, qué estamos modelando) y, posteriormente, la semántica operacional (la implementación del comportamiento, es decir, cómo vamos a modelarlo).
Ahh… las antiguas escrituras, donde los enanos grabaron en piedra las runas que definen el poder del Anillo. Un conocimiento ancestral que dicta cómo cada pieza del metal debe ser moldeada para encerrar su verdadero poder.
Siguiendo con el ejemplo de hacer logins, queremos determinar el comportamiento en función de las distintas formas. Esto es, va a estar parametrizado por los tipos asociados que vamos a definir para las distintas vías de loguearse:
// User-Password Strategy
case class UserPasswordStrategy(user: User, password: Password)
case class User(name: String, email: Email)
case class Password(value: String)
// Token Strategy
case class TokenStrategy(token: Token, updatedAt: Long)
case class Token(value: String)
// Magic-Link Strategy
case class EmailMagicLinkStrategy(email: Email, magicLink: MagicLink)
case class Email(value: String)
case class MagicLink(value: String)
type Result[A] = Either[Error, A]
// Response
case class AuthResponse(message: String)
// Error
case class Error(message: String)
Y ahora sí, definamos el comportamiento de login, es decir, definamos la typeclass:
trait Login[L] {
def login(request: L): Result[AuthResponse]
}
Pues ya estaría. Con esto ya tenemos la primera parte. Podemos afinarla mucho más y añadirle una serie de helpers, como por ejemplo en su companion object:
object Login {
def apply[L: Login]: Login[L] = implicitly[Login[L]]
}
O algún método helper en algún cajón desastre utils:
def login[L: Login](request: L): Result[AuthResponse] = Login[L].login(request)
Instancias, Implementaciones o Intérpretes #
Ahora viene la parte divertida: forjar el anillo único.
La anterior, es decir, la definición de la typeclass, es en general la parte más compleja. Hay que considerar cómo queremos que el resto de nuestro proyecto interactúe con nuestro trait para hacer login. Debemos hacer un ejercicio de reflexión y abstracción nada trivial, y, a su vez, considerar qué cosas vamos a necesitar para las futuras implementaciones.
En esta segunda parte la cosa es diferente. Cuando queremos implementar, aterrizamos la idea. Bajamos del plano celestial al mundo terrenal.
Forjar el Anillo Único en las profundidades de la montaña. Cada martillazo, cada chispa que salta del yunque, es la materialización de las antiguas runas en el metal incandescente, dándole forma y propósito.
Para hacer las diferentes instancias o intérpretes de nuestra typeclass para los distintos métodos o estrategias de login, debemos hacer las siguientes implementaciones:
implicit val userPassLogin: Login[UserPasswordStrategy] = new Login[UserPasswordStrategy] {
override def login(request: UserPasswordStrategy): Result[AuthResponse] = ???
}
implicit val tokenLogin: Login[TokenStrategy] = new Login[TokenStrategy] {
override def login(request: TokenStrategy): Result[AuthResponse] = ???
}
A partir de la versión de Scala 2.13, la sintaxis se vuelve menos verbosa, pero la estructura es la misma al fin y al cabo:
implicit val userPassLogin: Login[UserPasswordStrategy] = (request: UserPasswordStrategy) => ???
implicit val tokenLogin: Login[TokenStrategy] = (request: TokenStrategy) => ???
Y ya con esto estaría. Antes de ver cómo se usan, veamos primero este mecanismo en otros lenguajes.
Typeclass en otros lenguajes #
Las typeclasses son un poderoso mecanismo de abstracción en lenguajes como Haskell, y tienen un concepto similar en Rust a través de los traits. Aunque ambos sirven para lograr polimorfismo ad-hoc, sus implementaciones tienen diferencias clave.
Tanto en Haskell como en Rust, las typeclasses son muy utilizadas. Podría decirse que gran parte del código que puedas hacer o usar en estos lenguajes se basa en ellas. Es más, tienen su propio mecanismo para la generación de las instancias o intérpretes de estas: la derivación o deriving.
Con la derivación automática puedes generar implementaciones estándar de typeclasses (en Haskell) o traits (en Rust) sin necesidad de escribir el código manualmente. Es una técnica clave para reducir la repetición de código cuando se implementan patrones comunes.
Haskell: Typeclass y Derivación #
Las typeclasses comunes como Eq, Ord, Show, Read, etc., se pueden derivar automáticamente. No todas las typeclasses pueden hacerlo obviamente. Las creadas por el desarrollador evidentemente no obtienen ese mecanismo de manera inmediata.
data Persona = Persona { nombre :: String, edad :: Int }
deriving (Show, Eq, Ord)
main :: IO ()
main = do
let p1 = Persona "Ana" 30
p2 = Persona "Juan" 25
print p1 -- Persona {nombre = "Ana", edad = 30}
print (p1 == p2) -- False
print (p1 > p2) -- True
-- Simulated result and response types
data Result a = Success a | Failure String deriving (Show)
data AuthResponse = AuthResponse { token :: String } deriving (Show)
-- Login typeclass
class Login l where
login :: l -> Result AuthResponse
-- Example implementation for a specific type
data LoginRequest = LoginRequest { username :: String, password :: String }
instance Login LoginRequest where
login (LoginRequest "admin" "password") = Success (AuthResponse "valid_token_123")
login _ = Failure "Invalid credentials"
Rust: Typeclass y Derivación #
Algunas de las derivaciones automaticas que soporta Rust con para traits comunes como Clone, Copy, Debug,PartialEq, Eq,PartialOrd, Ord, Hash.
En Rust, la estructura para hacer typeclasses son los traits:
#[derive(Debug, PartialEq, Eq, PartialOrd, Ord)]
struct Persona {
nombre: String,
edad: u32,
}
fn main() {
let p1 = Persona { nombre: "Ana".to_string(), edad: 30 };
let p2 = Persona { nombre: "Juan".to_string(), edad: 25 };
println!("{:?}", p1); // Persona { nombre: "Ana", edad: 30 }
println!("{}", p1 == p2); // false
println!("{}", p1 > p2); // true
}
De hecho, gracias a esto junto a la crate serde, nos permite derivar automáticamente los traits Serialize y Deserialize, haciendonos super sencillo la serialización en json desde ADTs:
use serde::{Serialize, Deserialize};
#[derive(Debug, Serialize, Deserialize)]
struct Persona {
nombre: String,
edad: u32,
}
Nuestra implementación de login en Rust sería de la siguiente forma:
// Simulated result and response types
#[derive(Debug)]
pub enum Result<T> {
Success(T),
Failure(String),
}
#[derive(Debug)]
pub struct AuthResponse {
pub token: String,
}
// Login trait
pub trait Login {
fn login(&self) -> Result<AuthResponse>;
}
// Implementation of the trait for a specific type
pub struct LoginRequest {
pub username: String,
pub password: String,
}
impl Login for LoginRequest {
fn login(&self) -> Result<AuthResponse> {
if self.username == "admin" && self.password == "password" {
Result::Success(AuthResponse { token: "valid_token_123".to_string() })
} else {
Result::Failure("Invalid credentials".to_string())
}
}
}
// Example usage
fn main() {
let request = LoginRequest {
username: "admin".to_string(),
password: "password".to_string()
};
match request.login() {
Result::Success(response) => println!("{:?}", response),
Result::Failure(err) => println!("Error: {}", err),
}
}
Scala 3: Una nueva esperanza #
Y lo cierto es que, desde el cambio en la sintaxis de Scala 2 a Scala 3, y con todo el conjunto de funcionalidades que enriquecen la metaprogramación, tomando ideas de muchos lenguajes, Scala 3 presenta una mejoría en la construcción y uso de mecanismos de abstracción, en general y en particular de las typeclasses.
Tomando de base nuestro código, la gran parte es igual excepto por la creación de las instancias:
// Typeclass instance for LoginRequest
given Login[LoginRequest] with
def login(request: LoginRequest): Result[AuthResponse] =
if (request.username == "admin" && request.password == "password")
Success(AuthResponse("valid_token_123"))
else
Failure("Invalid credentials")
Para la derivación es un poco más tricky, pero tiene un gran potencial:
import scala.deriving.Mirror
import scala.compiletime.erasedValue
// Typeclass definition
trait Show[T] {
def show(value: T): String
}
// Typeclass instances for primitive types
given Show[Int] with
def show(value: Int): String = value.toString
given Show[String] with
def show(value: String): String = s""""$value""""
// Automatic derivation logic
object Show:
inline def derived[T](using m: Mirror.Of[T]): Show[T] = new Show[T]:
def show(value: T): String = value.toString // Simple logic for demonstration
// Data model with derived instance
case class Product(name: String, price: Int) derives Show
Conclusión #
Las typeclasses son una herramienta fundamental en la programación funcional que nos permite modelar comportamientos de forma clara, escalable y segura. Gracias a su capacidad para abstraer funcionalidades comunes, podemos escribir código más flexible y extensible, promoviendo buenas prácticas como el polimorfismo ad-hoc y el tipado fuerte.
En este post hemos visto cómo definir una typeclass en Scala 2, crear sus instancias e incluso explorar sus similitudes con otros lenguajes como Haskell y Rust. Si bien el diseño inicial puede requerir un esfuerzo adicional para identificar el comportamiento que queremos modelar, el resultado final aporta una base sólida que facilita el mantenimiento y la evolución del código.
En próximos posts veremos cómo utilizar estas typeclasses en la práctica. ¡Nos vemos en el siguiente capítulo!