Writing Pure, Testable, Effectful Programs: A Saga
In this post, I'll try to walk you through my journey in writing a testable, pure, effectful program in PureScript. Hopefully it will be useful in illustrating the types of problems more advanced techniques like monad transformers, free monads, and bifunctor IO try to solve.
This post assumes basic knowledge of monadic effects. How they are defined, how they are used, etc.
Let's say we need to write a program with the following requirements:
- Read a string from a file ("message.txt")
- Append a signature to the message string read from file (" - Danny Andrews")
- Write the message + signature out to the console
- If file read fails, writes a custom error message telling you what happened
- The program is completely testable, allowing us to pass doubles in place of the actual effectful functions (in this case, Fs.readTextFile.)
Seems simple enough, right? Well, buckle up.
Here's my first pass:
module Main where import Prelude import Effect (Effect) import Effect.Console (log) import Node.FS.Sync as FS import Node.Encoding (Encoding(..)) getSignedMessage :: String -> Effect String getSignedMessage signature = do result <- FS.readTextFile UTF8 "message.txt" pure $ result <> signature main :: Effect Unit main = do result <- getSignedMessage " - Danny Andrews" log result
This gets us through the happy case, but it doesn't satisfy requirement #4. If the file "message.txt" doesn't exist, our program crashes with a generic file read error. Fortunately, PureScript provides a method for converting a function which returns an
Effect a to one which returns an
Effect (Either Error a) called
try defined in Effect.Exception. So let's use that.
module Main2 where import Prelude import Effect (Effect) import Effect.Console (log) import Node.FS.Sync as FS import Node.Encoding (Encoding(..)) import Effect.Exception (try) import Data.Either (Either(..)) import Data.Bifunctor (lmap) data MessageFileReadError = MessageFileReadError instance showMessageFileReadError :: Show MessageFileReadError where show s = "Could not read message file 'message.txt.' Does it exist?" readMessageFile :: Effect (Either MessageFileReadError String) readMessageFile = do result <- try $ FS.readTextFile UTF8 "message.txt" pure $ lmap (const MessageFileReadError) result getSignedMessage :: String -> Effect (Either MessageFileReadError String) getSignedMessage signature = do result <- readMessageFile pure $ map (_ <> signature) result main :: Effect Unit main = do result <- getSignedMessage " - Danny Andrews" case result of Right a -> log a Left err -> log $ show err
This code runs great, but it's annoying that we have to do an extra
map operation to append the signature (
pure $ map (_ <> signature) result). As it turns out, working with nested monads is a common occurance, and the canonical solution to this problem in the haskell and scala community is to use monad transformers. I won't explain monad transformers in detail here, but I'll show you how to use
ExceptT which is sort of analogous to
OptionT if you're familiar with those.
Here's our example one more time:
module Main3 where import Prelude import Effect (Effect) import Effect.Console (log) import Node.FS.Sync as FS import Node.Encoding (Encoding(..)) import Effect.Exception (try, Error) import Data.Either (Either(..)) import Control.Monad.Except.Trans (ExceptT(..), runExceptT) readTextFile :: Encoding -> String -> ExceptT Error Effect String readTextFile encoding path = ExceptT $ try $ FS.readTextFile encoding path getSignedMessage :: String -> ExceptT Error Effect String getSignedMessage signature = do result <- readTextFile UTF8 "message.txt" pure $ result <> signature main :: Effect Unit main = do result <- runExceptT $ getSignedMessage " - Danny Andrews" case result of Left err -> log "oh no" Right message -> log message
What we've done here is changed our functions to return
ExceptT, parameterized with the types we're interested in, which allows us to call
bind once, and transform the underlying value. Awesome!
This code meets requirements 1-4. However, there is still one issue:
It's completely untestable. The output type of our
getSignedMessage function is
Effect (Either Error String) and as you may or may not know,
Effect types are not comparable. (This follows from the fact that functions are not comparable.) So there's no way for us to make assertions about it. The solution to this problem is where things get pretty wild.
When looking for a solution, you will hear people throwing around terms like "mtl," "extensible effects," "finally-tagless," "free monad," "free(er) monad," and the like. It's all pretty overwhelming.1
This is where I'll leave this post. Hopefully someone more experienced with functional programming can clue me in on how to do this, or convince me that I shouldn't worry so much about integration testing in a pure, strongly-typed functional language. I'd love to hear people's opinions on the matter.
1 It blows me away that trying to write a testable program in a pure functional language is so complex. I'm not claiming it's a trivial problem, but it seems like there's so many vastly different ways to accomplish this, many of them requiring you to structure your application in a very specific way, and it is very overwhelming for a beginner. But, I guess no one ever said pure functional programming was easy.