Pure Monad Stacks in Elm

I finally published elm-stax 1.0.0 which is more philosophically interesting than useful, but it shows that Monad Transformers are not as necessary as one might think. Monad Transformers in Haskell/PureScript allow one to construct monad stacks out of transformers ReaderT, WriterT, StateT, ExceptT, MaybeT with either Identity at the base for pure or IO (Haskell) or Effect/Aff(PureScript) for effectful. So if considering pure stacks only, the truth is that there are only about 19 useful / reasonable combinations! :laughing: So what occurred to me is that if you offer just TWO stacks Reader/Writer/State/Except and Reader/Writer/State you get all the combinations by just choosing one or the other and plugging in Unit () for the ones you don’t care about! And using () as your Error basically gives you MaybeT.

So it is a neat example of “This thing is really powerful in Haskell… oh but you can kinda do 80% of it in Elm with great error messages and blazing fast compile times.”. It is also useful if you’ve ever heard the term Monad Transformer or Monad Stack and thought it was some scary thing. It isn’t. You can go look at the boneheaded code in my library. Now, it isn’t a transformer library per se. It is a concrete hard coded implementation of two stacks, but you can still see how it is possible to layer together multiple monads.

This code has been sitting on my computer for three years and I finally decided to publish it because I was just laid off (modernization project I was supposed to lead had been terminated).

7 Likes

Very interesting, thanks for publishing.

A question - reader gives you config you can read, writer gives you somewhere to send output - state carries state. But to be useful you need to read and write the state, correct ?

So is the state strictly needed ? Can I get it by plugging in something that bridges read and write and carries the state.

If anything Reader and Writer are not strictly needed. The distinction between Reader, Writer, and State is largely about semantics and safety more than powers or capabilities. If you had just State you could

  • put configuration in the state and read it out
  • put logs in the state and then just write to them.

So, if you wanted, if you had no need to fail fast with errors and so only needed RWS then you could instead use the elm-state 3.0.1 package. If you wanted fail fast you could technically just have a StateT ExceptT.

However, what discriminating gives you is a loud signal about how the data should be used. Configuration provided by the Reader is not returned as a result when you run the monad stack. Anything in Reader declares “Hey! This data is read-only.”. Conversely, things written (tell) to the writer are invisible to the rest of the stack. If some prior computation wrote something that should not impact subsequent computations because written/logged things invisible - and that is supposed to be a good thing. Now code in a parent stack can run child stacks manually and inspect their logs BUT a stack cannot see the logs of prior and you have to got out of your way to break the contract for children.

For example, if you had a REALLY complicated computation that needed to run for an Elm component you might do something like

-- a Stack for a given Model, Msg pair 
type alias Stack config a =
    RWSE config (Cmd Msg) Model Error a

-- This computation is clear it needs only a personApi and it produces no value
fetchPerson : PersonId -> Stack { r | personApi : PersonApi } ()
fetchPerson =
    LocalCache.fetchIfNecessary personCacheLens (.personApi >> .getPerson) ReceivePerson

fetchPet : PetId -> Stack { r | petApi : PetApi } ()
fetchPet = 
    LocalCache.fetchIfNecessary petCacheLens (.petApi >> getPet) ReceivePet

update : { r | personApi : PersonApi, petApi : PetApi } -> Msg -> Model -> ( Model, Cmd Msg )
update config msg model =
    let
        runStack stack = 
            case S.runWith config model stack of
                (newModel, cmds, Ok ()) ->
                    ( newModel, Cmd.batch cmds )

                (_, _, Err apiError) ->
                    ( { model | error = ApiError.display apiError }, Cmd.none )
    in
    case msg of
        LoadPeople personIds ->
            runStack (S.for_ personIds fetchPerson)

        ReceivePerson (personId, personResult) ->
            LocalCache.receive personCacheLens personId personResult

        LoadPets petIds -> 
            runStack (S.for_ petIds fetchPet)

        ReceivePet (petId, petResult) -> 
            LocalCache.receive petCacheLens petId petResult

Where the fetchIfNecessary might be

fetchIfNecessary :
    Lens model (Sort.Dict id (RemoteData ApiError a))
    -> (config -> (id -> Cmd (Result ApiError a)))
    -> (id -> Result ApiError a -> msg)
    -> id
    -> RWSE config (Cmd msg) model ApiError ()
fetchIfNecessary lens fetchFromConfig returnToMsg id =
    let
        load =
            S.ask 
                |> S.andThen (\config -> 
                    S.tell [ fetchFromConfig config id |> Cmd.map (returnToMsg id) ]
                )
                |> S.andThen_ (S.modify <| Lens.update lens <| Dict.insert id RemoteData.Loading)
    in
    S.get
        |> S.andThen
            (\model ->
                case Maybe.withDefault RemoteData.NotAsked <| Dict.get id (lens.get model) of
                    RemoteData.Success _ ->
                        S.doNothing

                    RemoteData.Loading ->
                        S.doNothing

                    RemoteData.NotAsked ->
                        load

                    RemoteData.Failure error ->
                        if ApiError.canNeverRecover error then
                            S.throw error

                        else
                            load -- we'll try again!
            )

Now all of this probably looks like a ton of ceremony for no justifiable reason but

  1. It is really useful when you looping where each iteration of the loop may need to update state and produce commands while potentially failing. for_, combine (sequence), combineMap (traverse), and foldM are REALLY useful when you need them.
  2. It provides a principled foundation for chaining model updates and command production. We have Cmd.Extra and a bunch of Apis in the community that solve the problem simply and work well for the 95% case but a Reader Write State Error monad stack is just a little more robust.

As a side note: I am aware that the example above could potentially send dozens or hundreds of independent requests which would often be a terrible idea due to Http overhead. 1. It was just an example. 2. In a real world scenario I might use this pattern if I had a service worker on the client that batched commands (waiting for a quiet period) and then sent them to an application gateway. If I did that I would be choosing client-side Api simplicity over performance.

3 Likes

This topic was automatically closed 10 days after the last reply. New replies are no longer allowed.