I have had an interest in Haskell for a long time. When I first encountered the language, resources such as Real World Haskell and Learn You a Haskell didn’t exist yet, so I tried to make do with the A “Gentle” Introduction to Haskell, which had me scratching my head a lot. At that time I even started writing a tool in Haskell to help my father run his business. But then other things came up and I was left with that superficial knowledge of Haskell that I always wanted to deepen but never got to.
A couple of months ago I stumbled on some praise online for a new Haskell book, which promised to teach everything from first principles. When I saw that the book included chapters on more recent topics such as Foldable
and Traversable
and on things I never properly got such as the Reader
monad and monad transformers, I decided to give it a try. I was not disappointed. One of the many a-ha moments I had reading the book was about those famous monad transformers.
I had an ingrained notion that I brought from other languages that Haskell data types were just either enumerations or data containers. For instance:
-- enumeration
data Bool = False | True
-- container
data BinTree a = Nil | Node a (BinTree a) (BinTree a)
Therefore I was puzzled when reading code examples given in the book that seemed to just be about pointless wrapping and unwrapping of data with a MaybeT
monad transformer. Then I realized that this wrapping and unwrapping was being done in order to have access to the Monad
instance of MaybeT
and use its instance functions against the wrapped data. In other words, types were being used not to hold other data, as in a collection, but to change the behaviour of that same data.
A small example should demonstrate why this is useful. Let’s start a module:
In this example, there are two text files with key-value pairs. The first one is a dictionary from Portuguese to English:
casa house
carro car
mala suitcase
mesa table
and the second one is a price list:
house 1000000
chair 2300
suitcase 600
pen 15
These data sources could be replaced by a database or a web service and the reasoning would be the same. In order to access those sources I/O needs to be performed and therefore the result will be embedded in the IO
type. Moreover, an optional result type (Maybe
) should be used in case we search for a non-existant key. Therefore the functions that retrieve data will be:
mkTable f = let mkPair (x:y:_) = (x, f y) in
fmap (mkPair . words) . filter (/= "") . lines
getItem :: String -> (String -> a) -> String -> IO (Maybe a)
getItem file f key = readFile file >>= return . lookup key . mkTable f
getTranslation :: String -> IO (Maybe String)
getTranslation = getItem "dict.txt" id
getPrice :: String -> IO (Maybe Int)
getPrice = getItem "prices.txt" read
We want to, given a product named in Portuguese, to have its price. The types of the results of those functions show that to access the data we want, we need to unwrap first the IO
type and then Maybe
. An attempt would be:
v1 :: String -> IO (Maybe Int)
v1 key = do
item <- getTranslation key
case item of
Nothing -> return Nothing
Just x -> getPrice x
The do
notation unwraps the IO
monad, and the Maybe
is unwrapped with a case
statement. Running this in ghci
:
Prelude> :l MaybeT.hs
*MaybeT> v1 "livro"
Nothing
*MaybeT> v1 "casa"
Just 1000000
*MaybeT>
It was necessary to, in this example, manually unwrap the Maybe
type embedded in IO
so as to call getPrice
on the result. Although in this case the effort was not very big, things can escalate quickly with more functions.
Let’s see a possible definition of MaybeT
:
This declaration shows that a single object named runMaybeT
is being held with the type m (Maybe a)
. It is the composition of a type m
we know nothing about, besides the fact it has also an instance of Monad
, and the Maybe
type we know, which is wrapped by m
. So the MaybeT
transformer is a way of accessing the innermost data bypassing the outer monad, which we don’t care about, without needing to manually unwrap it at every step, as the next example show:
v2 :: String -> IO (Maybe Int)
v2 key = runMaybeT $ do
item <- MaybeT $ getTranslation key
MaybeT $ getPrice item
It is not terribly shorter than the first version, but the advantages are clear when the number of steps increases. For each new function added to the chain a case
statement would have to be added to the first version, while in the second version the code is shorter and the flow is obvious. Besides, newtype
is a restricted version of data
that doesn’t exist at runtime: All this wrapping and unwrapping of MaybeT
seen in the code above will be elided by the compiler. Other monad transformers like EitherT
, ReaderT
, StateT
, etc. work in similarly, they let you abstract away the outer monad and work on the data you care about.
I am glad I bought this book. This insight alone made it worth for me.