I have a MonadReader
that generates data for an application I am working on. The main monad here generates the data based on some environment variables. The monad generates the data by selecting one of several other monads to run based on the environment. My code looks somewhat like the following with mainMonad
being the main monad:
data EnvironmentData = EnvironmentA | EnvironmentB
type Environment = (EnvironmentData, Integer)
mainMonad ::
( MonadReader Environment m
, MonadRandom m
)
=> m Type
mainMonad = do
env <- ask
case env of
EnvironmentA -> monadA
EnvironmentB -> monadB
monadA ::
( MonadReader Environment m
, MonadRandom m
)
=> m Type
monadA = do
...
result <- helperA
result <- helper
...
monadB ::
( MonadReader Environment m
, MonadRandom m
)
=> m Type
monadB = do
start <- local (set _1 EnvironmentA) monadA
...
result <- helper
...
helperA ::
( MonadReader Environment m
, MonadRandom m
)
=> m String
helperA = do
...
helper ::
( MonadReader Environment m
, MonadRandom m
)
=> m String
helper = do
...
The notable things here are:
- We have a main monad (
mainMonad
) that is both aMonadReader Environment
and aMonadRandom
. - The main monad makes calls out to the subservient monads
monadA
andmonadB
of the same type. - We have a fourth monad which serves as helper to
monadA
andmonadB
. monadB
makes a call out tomonadA
(but useslocal
to change the environment)
Most importantly:
- Whenever
monadA
orhelperA
is called theEnvironmentData
isEnvironmentA
and whenevermonadB
is called theEnvironmentData
isEnvironmentB
.
My code base is pretty much a scaled up version of this. There are more subservient Monads (12 at the moment but this will likely increase in the future), there are more helpers, and my EnvironmentData
type is a little more complex (my Environment
though is nearly identical).
The last bullet point is important because the EnvironmentData
is used in the helpers and having the wrong Environment
will lead to subtle changes in the results of the helpers.
Now my issue is that it can be pretty easy to miss a local
in my code and just call a monad directly with the wrong environment. I also fear calling a monad without using local
because I think that it is expecting an environment it is not. These are tiny and easy to make errors (I've done it several times already) and yhe results of doing this are often rather subtle and rather varied. This ends up making the symptoms of the problem rather hard to catch with unit testing. So I would like to target the problem directly. My first instinct was to add a clause to my unit test that says something along the lines of:
Call
mainMonad
check that over the course of evaluating it we never have a monad called with the wrong environment.
That way I can catch these mistakes without having to comb through the code very carefully. Now after thinking about this for a little while I have not come up with a very neat way to do this. I've thought of a couple of ways that do work but I am not quite happy with:
1. Hard crash when called with the wrong environment
I could fix this by adding a condition to the front of each monad that hard crashes if it detects it being called with the wrong environment. For example:
monadA ::
( MonadReader m
)
=> m Type
monadA = do
env <- view _1 ask
case env of
EnvironmentA -> return ()
_ -> undefined
...
The crash will be caught during unit testing and I will discover the issue. However this is not ideal since I would really prefer the customer to experience the slight issues caused by calling things with the wrong environment rather than a hard crash in the event that the test handler does not catch the issue. It sort of seems like the nuclear option. It isn't awful but is not satisfactory by my standards and the worst of the three.
2. Use type safety
I also tried changing the types of monadA
and monadB
so that monadA
could not be called directly from monadB
or vice versa. This is very nice in that it catches the problems at compile time. This has the issue of being a bit of a pain to maintain, and it is quite complex. Since monadA
and monadB
may each share a number of common monads of the type (MonadReader m) => m Type
each and every one of those has to be lifted as well. Really it pretty much guarantees that every line now has a lift. I'm not opposed to type based solutions but I don't want to have to spend a huge deal of time just maintaining a unit test.
3. Move the locals inside of the of the declaration
Each monad with a restriction on the EnvironmentData
could start with a boilerplate akin to:
monadA ::
( MonadReader Environment m
, MonadRandom m
)
=> m Type
monadA = do
env <- view _1 <$> ask
case env of
EnvironmentA ->
...
_ ->
local (set _1 EnvironmentA) monadA
This is nice in that it makes sure everything is always called with the right environment. However the issue is that it silently "fixes" errors in a way that unit-tests or type proofs don't. It really only prevents me from forgetting local
.
3.5. Remove the EnvironmentData
This one is basically equivalent to the last, perhaps a bit cleaner though. If I change the type of monadA
and monadB
to
( MonadReader Integer m
, MonadRandom m
)
=> m Type
then add a wrapper using runReaderT
withReaderT
(as suggested by Daniel Wagner below) to calls coming from and to my MonadReader Environment
s. I cannot call them with the wrong EnvironmentData
since there is no environment data. This has pretty much the exact issues of the last ones.
So is there a way I can ensure that my monads are always called from the correct environment?