I have written a number of functions using a monad transformer stack:
data Options
data Result
data Input
type Ingest a = EitherT String (ReaderT Options IO) a
foo :: Input -> Ingest Result
and so on. Now, most of these functions are fundamentally pure. I only need IO in one of these functions: this function reads a file, and logs (using log :: String -> IO ()
) that it has done so. So the impurity of this one function "infects" my whole codebase, making all these functions capable of doing IO even though they don't need to, except to call this one function. This is distasteful for two reasons:
A colleague suggested parameterizing Ingest
over a base monad type, which I like. Specifically, define a typeclass for reading the contents of a file, and provide an instance for IO as well as perhaps for some other monad to use in writing tests:
class Monad m => ReadFile m where
readFile :: FilePath -> m Text
instance ReadFile IO where
readFile = Data.Text.IO.readFile
A typeclass for logging already exists, so I could just use that existing typeclass. But then I'm not sure how to use this new class. Firstly, what do I replace my type alias with? I can't write
type Ingest m a = (Logging m, ReadFile m) => EitherT String (ReaderT Options m) a
because constraints are not permitted on type synonyms. Must I add that constraint to each of my functions? In principle this is nice because it flags the functions which may need to read a file, but in practice these functions are mutually recursive, and so all of them need that permission, making it a pain to write them all out.
I could define a newtype wrapper instead of a type synonym, but I don't think that makes things any better: I still have to add this new constraint to each of my functions.
Secondly, how do I actually call the functions of my new typeclass, from within the EitherT/ReaderT stack? I can't simply write
foo :: ReadFile m => FilePath -> Ingest m Text
foo = readFile
because this ignores the EitherT and ReaderT wrappers. Do I instead write this?
foo :: ReadFile m => FilePath -> Ingest m Text
foo = lift . lift $ readFile
Seems kinda a pain and quite fragile to the structure of the transformer stack. Do I write a number of instances like
instance ReadFile m => ReadFile (EitherT e m) where
readFile = lift readFile
? That seems like a frustrating amount of boilerplate as well.
Instead of using IO
directly, wrap it in a abstract newtype
e.g:
module IOLog(IOLog, logMsg) where
newtype IOLog a = IOLog (IO a)
instance Functor IOLog where ...
instance Monad IOLog where ...
logMsg :: String -> IOLog ()
logMsg = logIO . log
-- local definitions --
--
logIO :: IO a -> IOLog a
logIO = IOLog
log :: String -> IO ()
.
.
.
and use that to define Ingest
:
type Ingest a = EitherT String (ReaderT Options IOLog) a
In this way you can control what subset of I/O the rest of the program can use, for the price of an extra module - no type-system extensions needed!
If you love us? You can donate to us via Paypal or buy me a coffee so we can maintain and grow! Thank you!
Donate Us With