16 min read

Higher Kinds in C# with language-ext [Part 10- ReaderT monad transformer]

Higher Kinds in C# with language-ext [Part 10- ReaderT monad transformer]

This is a multi-part series of articles – and you'll need to know much of what's in the earlier parts to follow this – so it's worth going back to the earlier episodes if you haven't read them already:

In the last article we started looking into monad transformers. We mostly focused on the MaybeT transformer (OptionT in language-ext), lifting the IO monad into it to create an optional side-effect.

Later in the article I did a quick overview of a project (that I am working on) that creates 'domain monads' by wrapping monad-transformer stacks into new types that are then further composed with free-monads to create a small application architecture.

There is never a 'right way' to structure an application and I think it's risky to use blogs, or other short-form methods to share coding advice on the 'one true way', because it doesn't exist. However, I think when you see how domains, layers, components, and structure in-general, evolves out of compositional types (like monads and monad-transformers) then that light-bulb moment will happen where you can suddenly 'see' the structure of your architecture from 10,000 feet.

These moments of clarity were always missing for me in my former OOP life, whereas with the pure functional programming approach I feel I have total clarity, but also it feels like the architecture is fluid and malleable (it can change with requirements) in a way that just isn't there in the object-oriented world. This may well be a personal thing, so please don't take this as me saying "there's exactly one way to write code" – because all self-appointed internet experts really should be taken with a handful of salt!

Even though the architecture example (from the last article) was a small example, I hope that it whetted your appetite when thinking about how to use pure functional programming techniques to create structure in your programs!

To really benefit the most from this approach we should aim to make our entire code-base pure and getting the architecture right is part of that story. What I find valuable about this approach is that the architecture itself becomes composable. That's quite profound when you think about it and it certainly changes the way I write code now.

It was quite a lot to take in, of course, and I certainly wouldn't expect novice FP devs to get it straight-away. But, one of the biggest issues I've had with building language-ext has been how to put control of building functional types into the hands of the users of the library. Before the higher-kinds approach (demonstrated in this series) there was no way to write code that worked for all monads, or all functors, etc. Now you can.

And before we had monad-transformers there was no way to compose the monads that already existed into new 'super monads', which meant I was stuck writing OptionAsync, EitherAsync, TryOption, and other combination-monads. And you were stuck waiting for me to build various pre-combined monads. This issue no longer exists.

These abstractions now exist. And many of the core types needed for you to build your own functional stacks now exist. The days of waiting for me to build ValidationAsync<F, A> are over, just use the ValidationT<F, IO, A> monad transformer stack.

It may take a little while for the implications of the changes in v5 of language-ext to really materialise as opportunities for you. It's a style of programming and an approach to composition that just hasn't existed in C# to-date. So, unless you've come from a Haskell background, this might not seem to be quite as profound as it really is.

C# isn't Haskell of course, but the reason for all of this isn't to make C# into Haskell, it's to make the inertia of the C# developer track in the direction of pure functional programming. And it just so happens that Haskell (as a pure functional programming language) has solved many patterns we need and so the inspiration often comes from that language.

Let's take a look at another monad-transformer...

ReaderT<Env, M, A>

The ReaderT monad-transformer adds a static environment, Env, to a given monad M. We briefly covered the Reader<Env, A> monad in the Monads Continued article – this generalises the idea into a monad-transformer. So, for example, let's say we had some application configuration in a simple record type:

public record MyConfig(string SourceFolder);

We want to write pure code, so we can't just stick this in a global static variable.

One option is to pass it as an argument to every function that needs the configuration settings:

public static IO<string> readSourceText(string file, MyConfig config)
{
    var path = Path.Combine(config.SourceFolder, file);
    return IO.liftAsync(e => File.ReadAllTextAsync(path, e.Token));
}

This is perfectly valid, but it can start to get a bit messy and annoying. And, aside from the name of the type, it's not very declarative. If MyConfig had a less descriptive name then it might make the interface to the function more confusing.

Instead, we drop the MyConfig argument and use the ReaderT monad-transformer. We can stack it with the IO monad to get both a static environment and side-effects.

ReaderT will automatically thread the MyConfig value through the computation for us and do so declaratively:

public static ReaderT<MyConfig, IO, string> readSourceText(string file) =>
    from config in ReaderT.ask<IO, MyConfig>()
    let path = Path.Combine(config.SourceFolder, file)
    from text in IO.liftAsync(e => File.ReadAllTextAsync(path, e.Token))
    select text;

Notice how there isn't a MyConfig value passed as an argument to readSourceText. It somehow magics the configuration out of thin air!

The key to this is on the first line in the LINQ expression:

ReaderT.ask<IO, MyConfig>()

This static function: ask, somehow gets the MyConfig structure and loads it into the config variable. How can a static function retrieve a non-global state?

If we look at the definition of ReaderT<Env, M, A> we'll get some clues:

public record ReaderT<Env, M, A>(Func<Env, K<M, A>> runReader) : K<ReaderT<Env, M>, A>
    where M : Monad<M>, SemiAlternative<M>
{
    public K<M, A> Run(Env env) => 
        this.runReader(env);   

   ...    
}

Internally, ReaderT is a Func<Env, K<M, A>>. If we provide an Env then we get a K<M, A> back (which is constrained to be a Monad<M>).

There's also a SemiAlternative<M> constraint. Ignore that for now, I'll cover it in a later article.

This follows the transformer pattern. Except this is a little different to MaybeT and OptionT in that K<M, A> is the inner type, whereas with MaybeT and OptionT it was the outer type: K<M, Option<A>>.

This difference in implementation is all to do with what is best for the particular transformer. It makes sense to provide an environment for the inner monad to run in. Whereas, for the optional types, it makes sense for the result to be optional! In general, optional types are wrapped by the lifted monad, and stateful-computation types wrap the lifted monad.

When we come to run the ReaderT transformer, we must pass the configuration value to it, so it can be passed through the computation:

// Create the application configuration object.  
// Typically you would do this once
var configObj = new MyConfig("c:\\folder");

// Run the ReaderT.  This returns an IO<string>
var configTextIO = readSourceText("test.cs").Run(configObj);

The result is an IO<string>. The IO monad being that which was lifted into the transformer.

If we run the IO value then we get the raw text:

var configText = configTextIO.Run();
Reminder: You shouldn't exit the IO monad unless you're at the very edge of your application.

That doesn't explain how ask works from earlier. It somehow magicked the configuration out of thin air. Remember, we're working with a wrapped function here, which means we can defer the gathering of the configuration (make it lazy):

new ReaderT<Env, M, Env>(env => M.Pure(env));

So, we don't need to know the environment to implement ask, we just need to know we will be given it at some point. Here we take the env input and simply lift it into the M monad which exposes it as the bound value. Extremely simple, but also extremely effective.

It can be simplified further to:

new ReaderT<Env, M, Env>(M.Pure);

And even further when it's part of a function that can infer the return type:

public static ReaderT<Env, M, Env> ask<Env, M>() 
    where M : Monad<M>, SemiAlternative<M> =>
    new (M.Pure);

Everything in ReaderT therefore is lazy. We know the Run method will provide an Env value which we can use to invoke the encapsulated delegate and realise a concrete result. But, every other (non-Run) method on ReaderT must use standard function composition to achieve its behaviours.

For example, the Bind function:

public ReaderT<Env, M, B> Bind<B>(Func<A, ReaderT<Env, M, B>> f) =>
    new(env => M.Bind(runReader(env), x => f(x).runReader(env)));

We create a new Func that provides us with an Env env, we invoke our runReader function with the env value. It gives us the wrapped K<M, A> which we can M.Bind because it's constrained to be a Monad<M>. That gets us the A value from within the M monad (assigned to x). We pass the A to the f function provided. It returns a new ReaderT which we then invoke (with the same env). It returns a K<M, B> which is what M.Bind expects as a result!

This is how the environment is threaded through the monad-transformer and how it can be made available for use in the LINQ expression. The env is always available because every ReaderT has it given when invoked.

Readable

So, that's all awesome and everything, but when we use transformers it's often good to wrap up the stack into a new type for encapsulation reasons. The eagle eyed among you may realise that ReaderT<Env, IO, A> is essentially Eff<RT, A> from previous versions of language-ext. The Eff monad was designed to be an IO monad with a 'runtime' that allowed for injectable side-effects.

So, let's wrap it up and create our own simplified Eff monad. First, let's create the Eff<RT, A> type:

public record Eff<RT, A>(ReaderT<RT, IO, A> runEff) : K<Eff<RT>, A>;

This simply wraps the ReaderT<RT, IO, A> monad transformer.

Next, let's make Eff<RT, A> into a monad by implementing the Monad trait:

public class Eff<RT> : Monad<Eff<RT>>
{
    public static K<Eff<RT>, B> Bind<A, B>(K<Eff<RT>, A> ma, Func<A, K<Eff<RT>, B>> f) =>
        new Eff<RT, B>(ma.As().runEff.Bind(x => f(x).As().runEff));

    public static K<Eff<RT>, B> Map<A, B>(Func<A, B> f, K<Eff<RT>, A> ma) =>
        new Eff<RT, B>(ma.As().runEff.Map(f));

    public static K<Eff<RT>, A> Pure<A>(A value) =>
        new Eff<RT, A>(ReaderT.Pure<RT, IO, A>(value));

    public static K<Eff<RT>, B> Apply<A, B>(K<Eff<RT>, Func<A, B>> mf, K<Eff<RT>, A> ma) =>
        new Eff<RT, B>(mf.As().runEff.Apply(ma.As().runEff).As());

    public static K<Eff<RT>, A> LiftIO<A>(IO<A> ma) =>
        new Eff<RT, A>(ReaderT.liftIO<RT, IO, A>(ma));
}

Note how every method is just a wrapper around the calls to the ReaderT implementations (runEff is a ReaderT<RT, IO, A>). Also note that we have implemented LiftIO which isn't enforced (there's a default implementation) but this allows us to use IO operations in our LINQ expressions directly (we covered this in the last article).

Next, create our As and Run extensions and add some constructors:

public static class Eff
{
    public static Eff<RT, A> As<RT, A>(this K<Eff<RT>, A> ma) =>
        (Eff<RT, A>)ma;

    public static IO<A> Run<RT, A>(this K<Eff<RT>, A> ma, RT runtime) =>
        ma.As().runEff.Run(runtime).As();

    public static Eff<RT, A> Pure<RT, A>(A value) =>
        new (ReaderT.Pure<RT, IO, A>(value));

    public static Eff<RT, A> Fail<RT, A>(Error error) =>
        new (ReaderT.liftIO<RT, IO, A>(IO.Fail<A>(error)));
}

And that's it!

Of course, the real Eff monad comes with lots of helper functions that make working with effects easier. But pretty much everything is just lifting the existing functionality from the transformers into the Eff type. For example, to access the runtime is simply a ReaderT.ask:

public static Eff<RT, RT> runtime<RT>() =>
    new (ReaderT.ask<IO, RT>());

You should be getting the idea now that this is just a wrapper around the transformer stack. We could extend the capabilities by stacking more transformers if necessary.

The real reason I wanted to demonstrate this is because there is another trait-type especially for readers. It's called: Readable and any type that implements it becomes 'readable':

public interface Readable<M, Env>
    where M : Readable<M, Env>
{
    public static abstract K<M, A> Asks<A>(Func<Env, A> f);

    public static virtual K<M, Env> Ask =>
        M.Asks(Prelude.identity);

    public static abstract K<M, A> Local<A>(
        Func<Env, Env> f,
        K<M, A> ma);
}
For those who know Haskell, this is essentially MonadReader without inheriting the Monad constraint!

We can add it to the traits that Eff<RT> implements:

public class Eff<RT> : Monad<Eff<RT>>, Readable<Eff<RT>, RT>
{
    // ... implementations from earlier ...

    public static K<Eff<RT>, A> Asks<A>(Func<RT, A> f) =>
        new Eff<RT, A>(ReaderT<RT, IO, A>.Asks(f));

    public static K<Eff<RT>, A> Local<A>(Func<RT, RT> f, K<Eff<RT>, A> ma) =>
        new Eff<RT, A>(ma.As().runEff.Local(f));
}    

Readable just abstracts the idea of accessing a static environment away from the monad. It means we can write code that accepts any 'readable' type, whether it's Eff, ReaderT, Reader, or whatever bespoke type we have built.

Readeable also comes with some static functions that makes readable types easy to work with:

public static class Readable
{
    public static K<M, Env> ask<M, Env>()
        where M : Readable<M, Env> =>
        M.Ask;

    public static K<M, A> asks<M, Env, A>(Func<Env, A> f)
        where M : Readable<M, Env> =>
        M.Asks(f);

    public static K<M, A> asksM<M, Env, A>(Func<Env, K<M, A>> f)
        where M : Readable<M, Env>, Monad<M> =>
        M.Flatten(M.Asks(f));

    public static K<M, A> local<M, Env, A>(Func<Env, Env> f, K<M, A> ma)
        where M : Readable<M, Env> =>
        M.Local(f, ma);
}

Let's say our 'runtime' was just an int and we want to write a function that takes two Eff monads with int as their bound-values and then sum those values with the int that's in the runtime (the environment).

public static K<Eff<int>, int> addEff(Eff<int, int> mx, Eff<int, int> my) =>
   from x in mx
   from y in my
   from z in Eff.runtime<int>()
   select x + y + z;

This works just fine, we can access the runtime value via the Eff.runtime function. However, this function only works with one monadic type: Eff.

We can generalise it even further:

public static K<M, int> addRdr<M>(K<M, int> mx, K<M, int> my)
    where M : Readable<M, int>, Monad<M> =>
    from x in mx
    from y in my
    from z in Readable.ask<M, int>()
    select x + y + z;

Note how M is not only constrained to be a Monad<M> but it is also constrained to be a Readable<M, int>. So, it's bindable and readable! Let's try running it with some Eff monads:

var compute = addRdr(Eff.Pure<int, int>(100), Eff.Pure<int, int>(200));
var result = compute.Run(300).Run();  // 600

addRdr has no idea what monad we're running and it has no idea how we're managing our static environment. It is a complete abstraction away from the internals. The Eff monad supports IO side-effects, parallelism, resource tracking and auto-cleanup, error tracking, retries, repeats, ..., and a static environment. All of which could happen in the addRdr function (because mx and my are computations) – but the implementation doesn't care, it is abstracted away. All it cares about is that the values are bindable and readable!

This is the path to true abstraction – where everything is pure. We can safely abstract, because we know all of the components are playing by the rules!

You may think this kind of abstraction isn't that valuable. But, if you remember the real-world domain-monads example from the previous article – where I created a Db monad, a Service monad, and an Api monad – then it's not a massive stretch to imagine writing some very general support functions that work with all of those monads (using the Monad constraint) and accessing their configuration (using the Readable constraint).

For example, you could imagine a User value being in the environment and a getLoggedInUser function to access it from any monad that supports it:

public record User(string Id);
public record Session(User LoggedIn, DateTime LastAccess);

public static K<M, Session> getSession<M>()
    where M : ReaderM<M, Session> =>
    ReaderM.ask<M, Session>();

public static K<M, User> getLoggedInUser<M>()
    where M : ReaderM<M, Session> =>
    ReaderM.asks<M, Session, User>(s => s.LoggedIn);

We could expand that out to embed security roles and rights so we could do inline assertions throughout our code-base:

// Simple User type with list of roles it's a member of
public record User(string Id, Seq<Role> Memberships);

// Union type of rights
public abstract record Right; 
public record CanViewDemographic : Right;
public record CanSendInvoice : Right;

// Role with its allowed rights
public record Role(Seq<Right> Rights);

Next we can write the function exactly once to deal with access-rights:

public static K<M, Unit> assertHasRight<M, R>()
    where R : Right
    where M : Readable<M, Session>, Functor<M> =>
    getLoggedInUser<M>().Map(
        u => u.Memberships.Exists(
            role => role.Rights.Exists(
                right => right is R))
            ? Prelude.unit
            : throw new SecurityException("Access denied"));

Then at the start of an API call, for example, you could setup the User in a Session environment (pre-populating the rights and roles once per request). That Session can then be passed to the computation that represents the API request (via Run(session)) which will then automatically thread the session, user, and rights through your monadic stack.

The (read-only) user, security rights, and roles will automatically propagate through your code. Each monad that wants to have access to security credentials could just expose them via a Readable trait implementation.

We can then write our security assertions code in any monad that supports them:

public static Eff<Session, Unit> sendInvoice() =>
    from _ in assertHasRight<Eff<Session>, CanSendInvoice>()
    from r in doSendInvoice()
    select r;

Being able to write security code once and keep it tightly audited is the mark of a professional approach to software development. So these abstractions are not just academic examples of what can be done with pure functional programming, they're very real examples of how you can make your code more robust and more secure. You'll be the auditors best friend – although I'm not sure that's a good thing!

Local environments

Having a fixed static environment for your entire code-base is of course possible, but that will tend to expose more 'environment' than is necessary for any subsystem. And so, it would be good if we could swap out our larger environments for something smaller on-the-fly. This is where ReaderT.local(f, ma) and ReaderT.with(f, ma) comes in:

  • local maps an Env to an Env – this allows simple mutation of the environment. The ma computation is then run in that locally mutated environment. By the end of the call the environment is reset.
  • with maps an Env to an alternative type which can be any other value you like, but often it is derived from Env. For example, you may have an AppConfig which contains a DbConfig – it could make sense to only expose the DbConfig to your data-layer by mapping from AppConfig -> DbConfig.
Note that with is not available in the Readable trait and so you must use Reader.with and ReaderT.with to map the environment to a different type (meaning we can't fully generalise the environment mapping). For any type that encapsulates ReaderT or Reader it is worth adding your own equivalent of with to do environment mapping (if that makes sense for your type, some wrappers might have a fixed environment). This is a limitation brought about by how the higher-kinds are implemented (the Env is baked into the trait implementation) – I haven't thought of an easy solution to this yet!

Conclusion

I often show (and will continue to show) the IO monad being the monad that is lifted into the transformer. I'm mostly doing this because it's easier to see tangible examples of what it means to compose monads with transformers. Just remember that any monad can be lifted into ReaderT – it could be a Validation<F, A> to have an environment for your validators (for example).

One other thing to note is that ReaderT would usually be the outermost type in the transformer stack. This is usually because you want the inner monad(s) in the transformer stack to have access to the environment. This isn't a rule, it's just usually the most effective position.

The ReaderT transformer really is the first 'control' transformer that shows how capabilities can be 'bolted on' to existing monads in a way that starts to break away from the relatively 'small thinking' of optional types and other common FP topics. Being able to carry a – pure – environment through your code really is the first part of building a pure architecture.

In previous versions of language-ext we only have Reader which was just an environment carrying monad – nothing else, it didn't compose with anything and that meant it was probably never used (I certainly never used it). Now we have the ability to augment with readable traits and that is extremely powerful!

Next up will be the StateT transformer. Very much like the ReaderT transformer except the environment can be modified. Pure mutation? You bet!

Coming soon...


Subscribe

This site is an independent publication launched in February 2024 by Paul Louth. If you subscribe today, you'll get full access to the website as well as email newsletters about new content when it's available. Your subscription makes this site continue to exist. Thank you!

Fresh content, delivered

Stay up to date with new content sent straight to your inbox! No more worrying about whether you missed something because of a pesky algorithm or news feed.