This article is written using PureScript code snippets, but hopefully the learnings are valuable anywhere!
In a lot of apps, there is some sort of global immutable state that is accessed and used to decide what to present. There are two "classic" ways to represent this.
- As a
Function
- Using a
Reader
monad
The two are conceptually very similar, but I find myself increasingly using the Reader
monad. In this article, I'll show how both can be used and why I'm a fan of Reader
these days.
Function vs Reader - the basics
Let's create an app that reads a number age
from a global immutable state and produces a boolean. Using a function, that app could look like this:
app { age } = age > 18
app { age: 19 } -- true
app { age: 17 } -- false
Using a Reader
, the app could look like this:
app = ask <#> \{ age } -> age > 18
runReader app { age: 19 } -- true
runReader app { age: 17 } -- false
They're not that different: one is a function to which immutable arguments are passed, and one is a context in which immutable arguments are read.
Composition
When working with an environment or state, one big reason that functions are used instead of readers is because they can be composed. For example, let's say that we want to enrich our state with an extra variable ageAsString
before it reaches our interpreting function. We'll also modify the interpreting function to use both variables. Using composition, that would look like this:
addStringRep { age } = { age, ageAsString: show age }
app =
addStringRep
>>> \{ age, ageAsString } -> show age == ageAsString
app { age: 19 } -- true
app { age: 17 } -- true
The signature of app hasn't changed: it is still { age :: Int } -> Boolean
. What's changed is that we have squished function composition in there so that the internals contain { age :: Int } -> { age :: Int, ageAsString :: String }
composed with { age :: Int, ageAsString :: String } -> Boolean
.
With a little elbow grease, readers can be composed as well.
composeReadersFlipped ::
forall a b c. Reader b c -> Reader a b -> Reader a c
composeReadersFlipped = map <<< runReader
composeReaders ::
forall a b c. Reader a b -> Reader b c -> Reader a c
composeReaders = flip composeReadersFlipped
infixr 9 composeReadersFlipped as <|<
infixr 9 composeReaders as >|>
And now, we can do addStringRep
in Reader
-land.
addStringRep = ask <#> \{ age } -> { age, ageAsString: show age }
app =
addStringRep
>|> ask
<#> \{ age, ageAsString } -> show age == ageAsString
runReader app { age: 19 } -- true
runReader app { age: 17 } -- true
Where the reader wins
So far, the two syntaxes - Function
and Reader
- have been more or less equivalent. If anything, function is easier to work with because it is shorter and perhaps more intuitive.
However, where Reader
really shines is the way that you can interrupt composition to inspect the environment at any time.
Let's now create a new (more-than-slightly contrived) example that composes three functions before the final one.
addAgePlus1 { age } = { age, agePlus1: age + 1 }
addAgePlus2 { age, agePlus1 } =
{ age, agePlus1, agePlus2: age + 2 }
addAgePlus3 { age, agePlus1, agePlus2 } =
{ age, agePlus1, agePlus2, agePlus3: age + 3 }
app =
addAgePlus1
>>> addAgePlus2
>>> addAgePlus3
>>> \{ age, agePlus1, agePlus2, agePlus3 } ->
age + agePlus1 + agePlus2 + agePlus3 == 4 * age + 7
app { age: 19 } -- true
app { age: 17 } -- true
And in Reader
-land.
addAgePlus1 = ask <#> \{ age } -> { age, agePlus1: age + 1 }
addAgePlus2 =
ask
<#> \{ age, agePlus1 } ->
{ age, agePlus1, agePlus2: age + 2 }
addAgePlus3 =
ask
<#> \{ age, agePlus1, agePlus2 } ->
{ age, agePlus1, agePlus2, agePlus3: age + 3 }
app =
addAgePlus1
>|> addAgePlus2
>|> addAgePlus3
>|> ask
<#> \{ age, agePlus1, agePlus2, agePlus3 } ->
age + agePlus1 + agePlus2 + agePlus3 == 4 * age + 7
runReader app { age: 19 } -- true
runReader app { age: 17 } -- true
What if we want to terminate early if addAgePlus2
is greater than 55?
In the function version, it gets pretty clunky.
addAgePlus1 { age } = { age, agePlus1: age + 1 }
addAgePlus2 { age, agePlus1 } = { age, agePlus1, agePlus2: age + 2 }
addAgePlus3 { age, agePlus1, agePlus2 } = { age, agePlus1, agePlus2, agePlus3: age + 3 }
app =
addAgePlus1
>>> addAgePlus2
>>> \env@{ agePlus2 } ->
if agePlus2 > 55 then
false
else
env
# ( addAgePlus3
>>> \{ age, agePlus1, agePlus2, agePlus3 } ->
age + agePlus1 + agePlus2 + agePlus3 == 4 * age + 7
)
app { age: 19 } -- true
app { age: 100 } -- false
Every time we interrupt the composition to introspect the environment, we have to pass the environment env
to the continuation of the composition. That leads to at least three problems:
- The code becomes harder to follow.
- It makes it hard to refactor because we now have an extra
env
to carry around if we want to move parts of this out. - There is a chance, in complex projects, that we may accidentally modify
env
before passing it along, which breaks the abstraction where thewithX
functions augment the environment.
On the other hand, using readers, this problem goes away:
addAgePlus1 = ask <#> \{ age } -> { age, agePlus1: age + 1 }
addAgePlus2 =
ask
<#> \{ age, agePlus1 } ->
{ age, agePlus1, agePlus2: age + 2 }
addAgePlus3 =
ask
<#> \{ age, agePlus1, agePlus2 } ->
{ age, agePlus1, agePlus2, agePlus3: age + 3 }
app =
addAgePlus1
>|> addAgePlus2
>|> do
{ agePlus2 } <- ask
if agePlus2 > 55 then
pure false
else
addAgePlus3
>|> ask
<#> \{ age, agePlus1, agePlus2, agePlus3 } ->
age + agePlus1 + agePlus2 + agePlus3 == 4 * age + 7
runReader app { age: 19 } -- true
runReader app { age: 100 } -- false
The code interrupts the sequence to ask a question (is agePlus2
greater than 55?) and then continues without allowing us to touch the original environment passed to addAgePlus3
. This is one of the classic advantages of monads: they allow for computations to bifurcate using bind
(or do
).
Conclusion
The reader monad and functions are very similar. In a lot of category theory texts, they're treated as the same thing.
The virtue of reader monads has been touted mostly because it can be part of a monadic stack using a library like mtl
, which means that a monad can take on the quality of being a reader in addition to other qualities (like being a writer or allowing for exceptions). However, the usefulness of composing readers has been IMHO undervalued. Treating reader composition like function composition with the "magical" possibility to interrupt the composition anywhere in the chain, look at what's going, and keep going is a great reason to use readers.
If you're not using PureScript, fear not! Here are some other great reader monads:
Have fun with readers!
Top comments (1)
The cool thing is we can implement the respective Category and Semigroupoid instances, allowing us to use the same syntax, right?