Hello there! How’s it going? How’s your month starting off? Today, I’m bringing a topic I’ve been wanting to write about for a while.
“The world has changed… I feel it in the code, I see it in the designs, I smell it in the implementations.”
Throughout my professional experience, I’ve witnessed numerous attempts to build systems and design code patterns. Some as solid as Mithril itself, others as fragile as a cobweb in Moria. But among all those architectures, there’s one that stands out for its power, flexibility, and — for many — its mystery: Typeclasses.
Today, I want to take you on a journey to unravel this powerful tool of functional programming. Like the One Ring forged in Eregion, Typeclasses have the power to unite multiple implementations under a single abstraction, offering an extensible and elegant design… but only for those who dare to understand them.
So, grab your wizard staff, wield your sword, and follow me on this journey through the dark paths of ad-hoc polymorphism. Because today I’ll show you how this powerful technique can help you forge cleaner, more modular, and scalable code.
One abstraction to rule them all, one abstraction to find them,
One abstraction to bring them all and in the code bind them. 🧙♂️✨
Abstraction Models #
To begin this post, I’d like to first present a common problem: the well-known task of performing login. We’re all familiar with it; we’ve all had to log in to an application or platform at some point, and most of us know the multiple ways to do so: username-password, token (API Key, JWT, etc.), MagicLink, etc.
The goal is to implement that functionality in the most generic way possible, abstracting away all specific logic and doing so functionally.
Approach: Overloaded Functions #
Let’s take it step by step. A first, obvious, and trivial approach would be to define independent functions, one for each scenario:
// User-Password
def login(user: String, password: String): Result[Error, AuthResponse] = ???
// Token
def login(token: String): Result[Error, AuthResponse] = ???
At first glance, this seems fine, but what happens when we want to add a new function with the same syntax as the User-Password one? For example:
// Magic Link
def login(email: String, link: String): Result[Error, AuthResponse] = ???
The compiler won’t allow us to proceed; it will display an error like the following:
[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]
Overloaded functions tend to be tightly coupled to concrete types. If we want to add a new authentication method (like Magic Link), we’ll need to either modify an existing function or create a new one with unique input parameters that don’t match any previous implementation.
On the other hand, although it’s not the case here, each implementation could potentially have a different result type, complicating its use in generic flows.
// User-Password
def login(user: String, password: String): Result[Error, UserPassAuthResponse] = ???
// Token
def login(token: String): Either[String, (TokenAuthResponse, User, Boolean)] = ???
Approach: Overloaded Functions with newtypes #
The truth is that with newtypes, the scenario would be different. If we defined the corresponding ADTs for each authentication method, we could largely solve the first problem:
// 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] = ???
However, while this approach might be sufficient, we’d still have enough freedom for future implementations to become distorted, deformed, or even corrupted, potentially leading to harmful changes with disastrous consequences. Alarming, right?
And really, we must be mindful of these results, so it wouldn’t hurt to impose certain restrictions when implementing our code.
Does the SOLID principle ring a bell? What is Dependency Inversion? Exactly: the control flow of a program should not depend directly on concrete implementations but rather on interfaces or abstractions.
Approach: Polymorphic Function #
For this reason, we must unify a whole set of operations under a common contract or syntax that allows us to avoid coupling high-level logic with low-level logic. I’ll explain this in detail later.
The first pattern we know for abstraction is polymorphism. Defining a polymorphic function is one of the most common mechanisms in functional programming. However, contemplating and encompassing behavior in a generic way is sometimes far from trivial.
In fact, for this case, we must ask ourselves the following question: For every form of authentication, what must our code do to successfully authenticate?
def login[A <: Auth](auth: A): Result[Error, AuthResponse] = ???
I’d say that, in this case, it’s practically impossible to think of an implementation that could be valid for A.
Approach: Interpreter Function and Pattern Matching #
Another well-known mechanism in functional programming is pattern matching. In this case, we could create a single evaluator or interpreter function that processes each case (using pattern matching).
In this approach, a data structure (ADT) is defined to represent each authentication type, and pattern matching is used to handle each case.
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?
}
This already looks better. The use of pattern matching is very useful in most cases. I say most because, despite being a powerful tool, it has one small flaw: scalability.
Extension Issues #
Since the ADT is sealed, it requires us to define all possible authentication cases within the same object. The advantage is that the compiler will warn us if any case is missing. The downside is that this approach is not extensible, meaning new cases can’t be defined in separate modules or dependencies.
Additionally, imagine that our development team implements the UsernamePassword and TokenAuth cases. Ten years later, that code becomes stable, productive legacy code that we’d prefer not to touch; now we need to implement the new MagicLinkAuth. This means modifying all functions using pattern matching, not just our interpreter function but also every other affected function scattered across a sea of productive, legacy, and stable code (spaghetti code, anyone?).
Scary stuff.
Runtime Errors #
On the other hand, if we avoid sealing the ADT to allow extension, we risk omitting certain cases, potentially causing runtime errors.
Of course, we can mitigate this by using the _ wildcard as the final case. However, this raises new questions: How do we handle unexpected cases? Do we throw an exception? Handle the error? A generic implementation isn’t possible — or at least incredibly difficult.
Is There an Alternative? #
Regardless of the choice we make, potential errors will appear at runtime. And here’s the key question: Is there a functional abstraction that covers all cases, ensures compile-time errors, and remains extensible?
Can we make seawater sweet? Well… yes (the first part — the second one, probably not). But we’ll explore that in the next post.
Conclusion #
In this post, we explored various approaches to implementing a flexible and maintainable authentication functionality. From overloaded functions to pattern matching, each technique presents its pros and cons. The key lies in finding the balance between flexibility and control.
In the next post, we’ll dive into a powerful alternative that addresses these challenges in a clean, robust way: Typeclasses. 🚀