Haskell - Extracting IO

piq9117 profile image Ken Aguilar ・3 min read

There are more articles out there discussing this topic but this is my take on it. This topic can be long and complicated but I'll try and extract a section of it that we can focus on.

Let's do a contrived example. Let's say we have a function that reads a file, processes it by capitalizing all the characters, then writes the results to another file. This is the first iteration of this function.

{-# LANGUAGE OverloadedStrings #-}
module Lib where

import           Relude

-- text
import qualified Data.Text as T

-- 1. reads the file
-- 2. capitalizes all the characters
-- 3. writes result to the output path
processFile :: FilePath -> FilePath -> IO ()
processFile fp output = do
  inputFile <- readFileText fp
  writeFileText output ( T.toUpper inputFile )

This adequately performs the task we want. The problem comes when we want to test the text processing without interacting with the file system. Sure, we can unit test the text processing it self but what if we want to see how the text processing behaves in the processFile function?

I learned mtl, tagless final encoding, and started reading about free monads, fused-effects, etc to be able to extract IO out; among other things. Then, I totally ignored an easier technique. Which is to make the "side-effecty" functions into a parameter, and give it a basic type constraint of Monad instead of IO, like this

  :: Monad m
  => ( FilePath -> m Text )
  -> ( FilePath -> Text -> m () )
  -> FilePath
  -> FilePath
  -> m ()
processFileBase readFileF writeFileF inputPath outputPath = do
  inputFile <- readFileF inputPath
  writeFileF outputPath ( T.toUpper inputFile )

We can use some type alias to make it less confusing.

type ReadFileF m = FilePath -> m Text
type WriteFileF m = FilePath -> Text -> m ()

  :: Monad m
  => ReadFileF m
  -> WriteFileF m
  -> FilePath
  -> FilePath
  -> m ()

We've extracted out the IO. Yaaay! This function is now more flexible. We can pass in functions as long as they have a Monad instance. If we go back to our IO implementation we can provide it with functions from relude.

processFileWithIO :: MonadIO m => FilePath -> FilePath -> m ()
processFileWithIO inputPath outputPath = processFileBase

Another advantage of this technique is we can use another library and don't have to restructure our program. Let's say we ended up dropping relude and using
prelude instead. We can massage the writeFile and readFile functions from prelude so it can fit our program. Like so

readFilePrelude :: MonadIO m => FilePath -> m Text
readFilePrelude = liftIO . fmap T.pack <$> Prelude.readFile

writeFilePrelude :: MonadIO m => FilePath -> Text -> m ()
writeFilePrelude fp content = liftIO 
  $ Prelude.writeFile fp ( T.unpack content )

processFileWithIO :: MonadIO m => FilePath -> FilePath -> m ()
processFileWithIO inputPath outputPath = processFileBase

In testing, we can provide it different functions.

{-# LANGUAGE OverloadedStrings #-}
module ProcessFileSpec where

import qualified Data.HashMap.Strict as HS
import           Lib
import           Relude
import           Test.Hspec

textFileToProcess :: Text
textFileToProcess =
  "Letting the cat out of the bag is a whole lot easier than putting it back in."

spec :: Spec
spec = do
  describe "processFile" $ do
    it "will process the file and capitalize every character" $ do
      ioRef <- newIORef HS.empty
        outPath = "sample-output.txt"

        testReadFile :: Monad m => FilePath -> m Text
        testReadFile _ = pure textFileToProcess

        testWriteFile :: MonadIO m => FilePath -> Text ->  m ()
        testWriteFile outputPath content = liftIO $
          modifyIORef ioRef (\ref -> HS.insert outputPath content ref )

      processFileBase testReadFile testWriteFile "input-path.txt" outPath

      result <- readIORef ioRef

      shouldBe result
        $ HS.singleton outPath

Here we're using IORef but you can use Map, List, StateT, WriterT. It's totally up to you. Whatever suits your use-case.

This technique can take you a long way! It is also a good complement to techniques like mtl and tagless final encoding


Invert Your Mocks! - Matt Parsons

Posted on by:

piq9117 profile

Ken Aguilar


* Functional Programmer * All my posts are cross post from my site https://www.taezos.dev/notes.html


markdown guide