Skip to content

RWST monad transformer

Pre-release
Pre-release
Compare
Choose a tag to compare
@louthy louthy released this 17 Oct 22:05
· 119 commits to main since this release

The new RWST<R, W, S, M, A> monad-transformer is now fully-featured and ready for real-world use.

For the uninitiated, the RWST monad-transformer combines all of the effects of the Reader, Writer, State, and M monads into a single monad. You could imagine a type like this:

    ReaderT<R, WriterT<W, StateT<S, M>>, A> 

Which stacks three monad-transformers and the monad M into one type. The problem with that is too much transformer stacking leads to lots of nested lambdas. The RWST monad-transformer smushes the Reader/Writer/State into a single layer making it more performant.

You can use Unit for any of the type parameters if you only need two of the three capabilities. For example, if you only need reader and state effects:

    RWST<R, Unit, S, M, A>

Or, reader and writer effects, but not state:

    RWST<R, W, Unit, M, A>

etc.

There's next to no overhead for doing so.

It's also worth noting that RWST is a very common mega-monad for constructing domain-specific monads for applications. And so, even though it's generics heavy, you would normally wrap it up in a type that reduces the generics overhead.

Let's say we wanted to create an App monad-transformer. Something that carries app-config, app-state, but can also lift other monads into it.

First, create some records to hold the config and the state:

public record AppConfig(int X, int Y);

public record AppState(int Value)
{
    public AppState SetValue(int value) =>
        this with { Value = value };
}

Then create your App monad-transformer type. It is simply a record that contains a RWST monad-transformer:

public readonly record struct App<M, A>(RWST<AppConfig, Unit, AppState, M, A> runApp) : K<App<M>, A>
    where M : Monad<M>, SemigroupK<M>
{
    // Your application monad implementation
}

Then add some extensions to convert from the K type to the concrete type and to Run the App monad-transformer:

public static class App
{
    public static App<M, A> As<M, A>(this K<App<M>, A> ma)
        where M : Monad<M>, SemigroupK<M> =>
        (App<M, A>)ma;
    
    public static K<M, (A Value, AppState State)> Run<M, A>(this K<App<M>, A> ma, AppConfig config, AppState state)
        where M : Monad<M>, SemigroupK<M> =>
        ma.As().runApp.Run(config, state).Map(
            ma => ma switch
                  {
                      var (value, _, newState) => (value, newState)
                  });
}

This also drops the Unit output from the RWST.

Then implement the traits for App<M>. It should be a MonadT because it's a monad-transformer; a Readable because it's got an AppConfig we can read with ask; and Stateful because it's got an AppState that we get, gets, modify, and put:

public class App<M> : 
    MonadT<App<M>, M>,
    Readable<App<M>, AppConfig>,
    Stateful<App<M>, AppState>
    where M : Monad<M>, SemigroupK<M>
{
    public static K<App<M>, B> Bind<A, B>(K<App<M>, A> ma, Func<A, K<App<M>, B>> f) =>
        new App<M, B>(ma.As().runApp.Bind(x => f(x).As().runApp));

    public static K<App<M>, B> Map<A, B>(Func<A, B> f, K<App<M>, A> ma) => 
        new App<M, B>(ma.As().runApp.Map(f));

    public static K<App<M>, A> Pure<A>(A value) => 
        new App<M, A>(RWST<AppConfig, Unit, AppState, M, A>.Pure(value));

    public static K<App<M>, B> Apply<A, B>(K<App<M>, Func<A, B>> mf, K<App<M>, A> ma) => 
        new App<M, B>(mf.As().runApp.Apply(ma.As().runApp));

    public static K<App<M>, A> Lift<A>(K<M, A> ma) => 
        new App<M, A>(RWST<AppConfig, Unit, AppState, M, A>.Lift(ma));

    public static K<App<M>, A> Asks<A>(Func<AppConfig, A> f) => 
        new App<M, A>(RWST<AppConfig, Unit, AppState, M, A>.Asks(f));

    public static K<App<M>, A> Local<A>(Func<AppConfig, AppConfig> f, K<App<M>, A> ma) => 
        new App<M, A>(ma.As().runApp.Local(f));

    public static K<App<M>, Unit> Put(AppState value) => 
        new App<M, Unit>(RWST<AppConfig, Unit, AppState, M, Unit>.Put(value));

    public static K<App<M>, Unit> Modify(Func<AppState, AppState> modify) => 
        new App<M, Unit>(RWST<AppConfig, Unit, AppState, M, Unit>.Modify(modify));

    public static K<App<M>, A> Gets<A>(Func<AppState, A> f) => 
        new App<M, A>(RWST<AppConfig, Unit, AppState, M, A>.Gets(f));
}

Every member is simply a wrapper that calls the underlying RWST monad-transformer (which does all the hard work).

Then we can use our new App monad-transformer:

var app = from config in Readable.ask<App<IO>, AppConfig>()
          from value  in App<IO>.Pure(config.X * config.Y)
          from _1     in Stateful.modify<App<IO>, AppState>(s => s.SetValue(value)) 
          from _2     in writeLine(value) 
          select unit;

This leverages the Readable trait to get at the AppConfig, the leverages the Stateful trait to modify the AppState, and finally does some IO (the lifted M monad), by calling writeLine.

That's great and everything, but we want to make the underlying type disappear completely. So, if we then wrap up the Readable.ask and Stateful.modify in App specific functions:

public static App<M, AppConfig> config<M>() 
    where M : Monad<M>, SemigroupK<M> =>
    Readable.ask<App<M>, AppConfig>().As();

public static App<M, Unit> modify<M>(Func<AppState, AppState> f) 
    where M : Monad<M>, SemigroupK<M> =>
    Stateful.modify<App<M>, AppState>(f).As();

Then we can make the resulting code completely App-centric:

var app = from config in App.config<IO>()
          from value  in App<IO>.Pure(config.X * config.Y)
          from _1     in App.modify<IO>(s => s.SetValue(value)) 
          from _2     in writeLine(value) 
          select unit;

So, by simply wrapping up the RWST monad-transformer you can gain a ton of functionality without worrying how to propagate state, log changes (if you use the Writer part of RWST), or manage configuration. Very cool.

Just for completeness, this is what writeLine looks like:

static IO<Unit> writeLine(object value) =>
    IO.lift(() => Console.WriteLine(value));

I'll cover this topic in more detail in the next article of my Higher Kinds series on my blog.