DEV Community

loading...
Cover image for Composing readers

Composing readers

mikesol profile image Mike Solomon ・4 min read

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
Enter fullscreen mode Exit fullscreen mode

Using a Reader, the app could look like this:

app = ask <#> \{ age } -> age > 18
runReader app { age: 19 } -- true
runReader app { age: 17 } -- false
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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 >|>
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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:

  1. The code becomes harder to follow.
  2. 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.
  3. There is a chance, in complex projects, that we may accidentally modify env before passing it along, which breaks the abstraction where the withX 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
Enter fullscreen mode Exit fullscreen mode

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!

Discussion (1)

pic
Editor guide
Collapse
mateiadrielrafael profile image
Matei Adriel

The cool thing is we can implement the respective Category and Semigroupoid instances, allowing us to use the same syntax, right?