The Tagless Final Pattern
Some say that functional languages, such as Haskell, do not enjoy that phenomena known as 'design patterns' that their beloved object oriented language does. Now, as much as I'd like to question their definition of enjoyment, what I'll address today is something else; while design patterns are not as obvious as they are in OOP, they do exist in functional programming.
One such distinguishable such pattern is tagless-final. This pattern allows us to write code that creates and transforms specific datatypes, while allowing those datatypes to be extended if necessary. In a previous post of my blog I talked about free monads. I'd advise you check it out before reading this post, since this one works as an alternative.
Free monads allowed us to work with a monad purely based on the set of operations it must support. With tagless-final, we do something similar, but lifting two key restrictions,
- For one, it's not limited to monads; our unknown type can be anything, whether a monad, a functor, a number, etc.
- The actual type isn't constructed based on the required operations; instead, the type can be chosen arbitrarily so long as it supports those operations
This second point is the key to tagless-final: the 'interpreter' can choose any type they want, as long as it supports the required operations. This allows extension: we can combine functions that assume distinct set of operations seamlessly, interpreting them later with a type that supports both.
Let's define it ourselves. Don't worry; unlike free monads, tagless-final is quite straight-forward. The main idea is to define the 'operations' as a typeclass. As an example, we'll reimplement the Database example from my earlier "Free The Monads" blog post (if you haven't read it, I'd love if you did, but it's not really a requirement).
First, these are the operations we want to abstract over:
getName :: User -> Database String
setName :: User -> String -> Database ()
Now to make the tagless-final model, we'll simply put them into a typeclass. We'll replace the actual type (Database) with a variable, which the typeclass will abstract over.
class (Monad m) => Database m where
getName :: User -> m String
setName :: User -> String -> m ()
Why the 'Monad m' constraint? Because, besides the getName and setName operations, we also want the Database type to be a monad. In general, we can add any typeclass constraints to the type this way.
Now we can write any abstract Database operation by using a variable monad 'm' and the typeclass constraint we just defined, for example:
changeName :: (Database m) => User -> String -> m Bool
changeName user newname = do
currname <- getName user
if currname == newname then return False
else setName user newname >> return True
This example is quite dumb, but it allows us to demonstrate how to use the abstraction. We simply work with an unknown monad 'm', constraining it to have the required Database operations.
Interpreting such computations is quite easy; we just define an instance for the specific monad we want to interpret it into. For example, using Writer,
instance Database (Writer String) where
getName user = do
tell $ "Got name of a user! Fake returned"
return "Fake"
setName user name = do
tell $ "Set name of a user to " ++ name
return ()
And that's it. We can then use runWriter (or whichever monad we're interpreting into) on our computation to get the result.
Now as mentioned on the differences earlier, tagless-final is not just for monads; we can abstract any type into it. For example, the Num class is, in fact, a tagless-final encoding. Whenever we work with generic numeric types, we're using this pattern. For instance:
example :: (Num a) => a -> a
example n = (n+1)*2
This operation does not care what numeric type 'a' we use; it simply needs it to support addition and multiplication.
And what about the second difference I highlighted, extensibility? To understand this, let's go back to our Database example. We'll define a new set of operations:
class (Monad m) => Debugger m where
report :: String -> m ()
panic :: m a
These describe an entirely new set of operations a monadic computation can perform. However, thanks to the design pattern, we can actually use both of them in a new function:
changeNameOrPanic :: (Database m, Debugger m) => User -> String -> m ()
changeNameOrPanic user newname = do
report $ "Attempting to change name into " ++ newname ++ "..."
status <- changeName user newname
if status then return ()
else panic
From this code, we can note two details:
- On the one hand, we're reusing our original changeName function, which has no knowledge of the new Debugger class
- And on the other hand, this new function can enjoy the operations of both abstractions
This is what extensibility means: we can reuse old operations, as well as add new operations as we require them. Now, obviously, interpreting this function will require us to implement Debugger too. We can't do that with just Writer any more, because 'panic :: m a' requires short-circuiting behaviour. It is possible by composing Writer with Maybe, however, and I'll leave that as an exercise for the reader.