on
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 EitherT
or 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 map
and 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.