Skip to main content
  1. Posts/

The Forge of Typeclasses 🔥

·9 mins
Rafael Fernandez
Author
Rafael Fernandez
Mathematics, programming, and life stuff
One Typeclass to Rule Them All - This article is part of a series.
Part 2: This Article

Hello there! We continue with the series One Typeclass to Rule Them All. In this post, we’ll dive deeper. We’ll talk about what they are, how to define them, and how to reason with them.

the-forge-of-typeclasses-img-115.jpg

The Abstraction of Behavior
#

Before diving into details, let’s talk about typeclasses: What are they? What are they used for? What is their purpose?

Typeclasses are simply a design pattern for code. A structure or mechanism that fits into functional programming and offers several advantages. These advantages are exactly what we were looking for in the previous post: a way to abstract ourselves, functionally, in a scalable manner that allows us to catch errors at compile time.

Now then, apart from the technical purpose we are pursuing, what sense does it make? What is its purpose? A typeclass is used to model behavior.

This concept is crucial, and we’ll expand on it in this post. When we want to abstract functionality, aiming to cover the broadest range of cases possible, what we are ultimately trying to capture is behavior.

  • In the case of writing to a database: creating an entity in Postgres, persisting cache in Redis, recording the event in Cassandra, etc. Ultimately, we want the behavior of interacting with a persistence repository.

  • In the case of authentication via login: using the user-password method, token-based, MagicLink, etc. Ultimately, we want the behavior of authenticating.

  • In more abstract cases, monads (sequential computation behavior), semigroups (a closed binary operation), and monoids (the previous plus a neutral element. For example: lists with the concatenation operation and the empty list).

Defining which behavior to model is not trivial, nor is determining what it should look like. But what we do know for sure is that typeclasses are the resource/tool/design pattern that will help us model it.

Let’s bring this idea down to earth.

Typeclass Structure
#

Depending on the language, a typeclass will be implemented in one way or another. Since this post is about Scala, and specifically Scala 2, that will be our focus. However, I’ll provide some code snippets with examples in other languages like Rust, Haskell, or even Scala 3.

Definition, Trait, or Grammar
#

The first part we must address is the grammar/syntax of our typeclass. This is the contract that will be established and will first determine the denotational semantics (the meaning of the behavior, i.e., what we are modeling) and later the operational semantics (the implementation of the behavior, i.e., how we are modeling it).

Ahh… the ancient scriptures, where the dwarves carved into stone the runes that define the power of the Ring. An ancestral knowledge that dictates how each piece of metal must be forged to enclose its true power.

the-forge-of-typeclasses-img-116.webp

Following the example of handling logins, we want to determine behavior based on the various methods. That is, it will be parameterized by the associated types that we’ll define for the different login strategies:

// 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)

Now, let’s define the login behavior, that is, the typeclass definition:

trait Login[L] {  
  def login(request: L): Result[AuthResponse]  
}

And that’s it. With this, we have the first part covered. We can refine it much further and add a set of helper methods, for example, in its companion object:

object Login {  
  def apply[L: Login]: Login[L] = implicitly[Login[L]]  
}

Or a helper method in some utility module:

def login[L: Login](request: L): Result[AuthResponse] =  Login[L].login(request)

Instances, Implementations, or Interpreters
#

Now comes the fun part: forging the One Ring.

The previous step, defining the typeclass, is generally the most complex. We must consider how we want the rest of our project to interact with our trait to handle login. This requires careful reflection and abstraction, and at the same time, we must anticipate what features we will need for future implementations.

In this second part, things are different. When we want to implement, we ground the idea. We move from the celestial plane to the earthly realm.

Forging the One Ring in the depths of the mountain. Each hammer strike, each spark that leaps from the anvil, is the materialization of the ancient runes in the glowing metal, giving it shape and purpose.

the-forge-of-typeclasses-img-117.webp

To create different instances or interpreters of our typeclass for the various login methods or strategies, we must implement the following:

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] = ???  
}

From Scala 2.13 onwards, the syntax becomes less verbose, but the structure is the same:

implicit val userPassLogin: Login[UserPasswordStrategy] = (request: UserPasswordStrategy) => ???  
  
implicit val tokenLogin: Login[TokenStrategy] = (request: TokenStrategy) => ???

And that’s it. Before we see how to use them, let’s first examine this mechanism in other languages.

Typeclasses in Other Languages
#

Typeclasses are a powerful abstraction mechanism in languages like Haskell, and they have a similar concept in Rust through traits. Although both aim to achieve ad-hoc polymorphism, their implementations have key differences.

In both Haskell and Rust, typeclasses are widely used. It could be said that much of the code you write or use in these languages relies on them. Moreover, they have their own mechanism for generating instances or interpreters: derivation or deriving.

With automatic derivation, you can generate standard implementations of typeclasses (in Haskell) or traits (in Rust) without having to write the code manually. It’s a key technique for reducing code duplication when implementing common patterns.

Haskell: Typeclass and Derivation
#

Common typeclasses such as Eq, Ord, Show, Read, etc., can be derived automatically. Not all typeclasses can do this, of course. Those created by the developer do not automatically have this mechanism by default.

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 and Derivation
#

Some of the automatic derivations that Rust supports are for common traits such as Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, and Hash.

In Rust, the structure used to create typeclasses is called 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
}

In fact, thanks to this feature along with the serde crate, we can automatically derive the Serialize and Deserialize traits, making JSON serialization from ADTs incredibly simple:

use serde::{Serialize, Deserialize};

#[derive(Debug, Serialize, Deserialize)]
struct Persona {
    nombre: String,
    edad: u32,
}

Our login implementation in Rust would look like this:

// 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: A New Hope
#

The truth is that, with the syntax change from Scala 2 to Scala 3, and with the entire set of features that enrich metaprogramming — borrowing ideas from many languages — Scala 3 introduces significant improvements in the construction and usage of abstraction mechanisms, particularly typeclasses.

Based on our previous code, most of it remains the same except for the instance creation:

// 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")

For derivation, it’s a bit trickier, but it holds great potential:

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

Conclusion
#

Typeclasses are a fundamental tool in functional programming that allow us to model behaviors in a clear, scalable, and safe way. Thanks to their ability to abstract common functionalities, we can write more flexible and extensible code, promoting good practices such as ad-hoc polymorphism and strong typing.

In this post, we’ve seen how to define a typeclass in Scala 2, create its instances, and even explore its similarities with other languages like Haskell and Rust. While the initial design may require some extra effort to identify the behavior we want to model, the final result provides a solid foundation that simplifies code maintenance and evolution.

In upcoming posts, we’ll see how to use these typeclasses in practice. See you in the next chapter!

One Typeclass to Rule Them All - This article is part of a series.
Part 2: This Article