17 Traversable and stacked monads

This chapter covers

  • Traversables: handling lists of elevated types
  • Combining the effects of different monads

So far in the book you’ve seen a number of different containers that add some effect to the underlying value(s)—Option for optionality, IEnumerable for aggregation, Task for asynchrony, and so on. As our list of containers keeps growing, we’ll inevitably hit the problem of combining different containers:

  • If you have a list of Tasks that you want to execute, how can you combine them into a single Task that will complete when all the operations have completed?

  • If you have a value of type Task<Validation<T>>, how do you compose it with a function of type T Task<R> with the least amount of noise?

This chapter will give you the tools to combine the effects of different containers and show how to avoid an excess of nested containers.

17.1 Traversables: Working with lists of elevated values

Traverse is one of the slightly more esoteric core functions in FP, and it allows you to work with lists of elevated values. It’s probably easiest to approach through an example.

Imagine a simple command-line application that reads a comma-separated list of numbers entered by the user and returns the sum of all the given numbers. We could start along these lines:

using Double = LaYumba.Functional.Double;    
using String = LaYumba.Functional.String;    
 
var input = Console.ReadLine();
 
var nums = input.Split(',')  // Array<string>
   .Map(String.Trim)         // IEnumerable<string>
   .Map(Double.Parse);       // IEnumerable<Option<double>>

Exposes an Option-returning function for parsing a double

Exposes a static Trim function

We split the input string to get an array of strings and remove any whitespace with Trim. We can then map onto this list the parsing function Double.Parse, which has the signature string Option<double>. As a result, we get an IEnumerable<Option<double>>.

Instead, what we really want is an Option<IEnumerable<double>>, which should be None if any of the numbers failed to parse. In this case, we can warn the user to correct their input.1 We saw that Map yields a type where the effects are stacked up in the opposite order than the one we need.

This is a common enough scenario that there’s a specific function called Traverse to address it, and a type for which Traverse is defined is called a traversable. Figure 17.1 shows the relationship between Map and Traverse.

Figure 17.1 Comparing Map and Traverse

Let’s generalize the idea of a traversable:

  • We have a traversable structure of T’s, so let’s indicate this with Tr<T>. In this example, it’s IEnumerable<string>.

  • We have a world-crossing function, f : T A<R>, where A must be at least an applicative. In this example, it’s Double.Parse, which has type string Option<double>.

  • We want to obtain an A<Tr<R>>.

The general signature for Traverse is in this form

Tr<T>  (T  A<R>)  A<Tr<R>>

Particularized for this example, it’s

IEnumerable<T>  (T  Option<R>)  Option<IEnumerable<R>>

17.1.1 Validating a list of values with monadic Traverse

Let’s see how we can go about implementing Traverse with the preceding signature. If you look at the top-level types in the signature, you’ll see that we start with a list and end up with a single value. Remember, we reduce lists to a single value using Aggregate, which was covered in section 9.6.

Aggregate takes an accumulator and a reducer function, which combines each element in the list with the accumulator. The accumulator is returned as the result if the list is empty. This is easy; we just create an empty IEnumerable and lift it into an Option using Some, as the following listing shows.

Listing 17.1 Monadic Traverse with an Option-returning function

public static Option<IEnumerable<R>> Traverse<T, R>
(
   this IEnumerable<T> ts,
   Func<T, Option<R>> f
)
=> ts.Aggregate
(
   seed: Some(Enumerable.Empty<R>()),    
   func: (optRs, t) =>
      from rs in optRs                   
      from r in f(t)                     
      select rs.Append(r)                
);

If the traversable is empty, lifts an empty instance

Extracts the accumulated list of R's from the Option

Applies the function to the current element, and extracts the value from the resulting Option

Appends the value to the list, and lifts the resulting list into an Option

Now, let’s look at the reducer function—that’s the interesting bit. Its type is

Option<IEnumerable<R>>  T  Option<IEnumerable<R>>

When we apply the function f to the value t, we get an Option<R>. After that, we must satisfy the signature:

Option<IEnumerable<R>>  Option<R>  Option<IEnumerable<R>>

Let’s simplify this for a moment by removing the Option from each element:

IEnumerable<R>  R  IEnumerable<R>

Now it becomes clear that the problem is that of appending a single R to an IEnumerable<R>, yielding an IEnumerable<R> with all the elements traversed so far. The appending should happen within the elevated world of Option because all the values are wrapped in an Option. As you learned in chapter 10, we can apply functions in the elevated world in the applicative or the monadic way. Here we use the monadic flow.

Now that you’ve seen the definition of Traverse, let’s go back to the scenario of parsing a comma-separated list of numbers typed by the user. The following listing shows how we can achieve this with Traverse.

Listing 17.2 Safely parses and sums a comma-separated list of numbers

using Double = LaYumba.Functional.Double;
using String = LaYumba.Functional.String;
 
var input = Console.ReadLine();
var result = Process(input);
Console.WriteLine(result);
 
static string Process(string input)
   => input.Split(',')        // Array<string>
      .Map(String.Trim)       // IEnumerable<string>
      .Traverse(Double.Parse) // Option<IEnumerable<double>>
      .Map(Enumerable.Sum)    // Option<double>
      .Match
      (
         () => "Some of your inputs could not be parsed",
         (sum) => $"The sum is {sum}"
      );

In the preceding listing, the top-level statements perform I/O, while all the logic is in the Process function. Let’s test it to see the behavior:

Process("1, 2, 3")
// => "The sum is 6"
 
Process("one, two, 3")
// => "Some of your inputs could not be parsed"

17.1.2 Harvesting validation errors with applicative Traverse

Let’s improve error handling so we can tell the user which values are wrong. For that, we need Validation, which can contain a list of errors. This means we’ll need an implementation of Traverse that takes a list of values and a Validation-returning function. This is shown in the following listing.

Listing 17.3 Monadic Traverse with a Validation-returning function

public static Validation<IEnumerable<R>> TraverseM<T, R>
(
   this IEnumerable<T> ts,
   Func<T, Validation<R>> f
)
=> ts.Aggregate
(
   seed: Valid(Enumerable.Empty<R>()),
   func: (valRs, t) => from rs in valRs
                       from r in f(t)
                       select rs.Append(r)
);

This implementation is similar to the implementation that takes an Option-returning function (listing 17.1) except for the signature and the fact that the Return function being used is Valid instead of Some. This duplication is due to the lack of an abstraction common to both Option and Validation.2

Notice that I’ve called the function TraverseM (for monadic) because the implementation is monadic. If one item fails validation, the validation function won’t be called for any of the subsequent items.

If, instead, we want errors to accumulate, we should use the applicative flow (if you need a refresher on why this is the case, refer back to section 10.5). We can, therefore, define TraverseA (for applicative) with the same signature but using the applicative flow, as the following listing shows.

Listing 17.4 Applicative Traverse with a Validation-returning function

static Func<IEnumerable<T>, T, IEnumerable<T>> Append<T>()
   => (ts, t) => ts.Append(t);
 
public static Validation<IEnumerable<R>> TraverseA<T, R>
(
   this IEnumerable<T> ts,
   Func<T, Validation<R>> f
)
=> ts.Aggregate
(
   seed: Valid(Enumerable.Empty<R>()),                     
   func: (valRs, t) =>
      Valid(Append<R>())                                   
         .Apply(valRs)                                     
         .Apply(f(t))                                      
);
 
public static Validation<IEnumerable<R>> Traverse<T, R>
   (this IEnumerable<T> list, Func<T, Validation<R>> f)
   => TraverseA(list, f);                                  

If the traversable is empty, lifts an empty instance

Lifts the Append function, particularized for R

Applies it to the accumulator

Applies f to the current element; the R wrapped in the resulting Validation is the second argument to Append.

For Validation, Traverse should default to the applicative implementation.

The implementation of TraverseA is similar to TraverseM, except that in the reducer function, the appending is done with the applicative rather than the monadic flow. As a result, the validation function f is called for each T in ts, and all validation errors will accumulate in the resulting Validation.

Because this is the behavior we usually want with Validation, I’ve defined Traverse to point to the applicative implementation TraverseA, but there’s still scope for having TraverseM if you want a short-circuiting behavior.

The following listing shows the program, refactored to use Validation.

Listing 17.5 Safely parsing and summing a comma-separated list of numbers

static Validation<double> Validate(string s)
   => Double.Parse(s).Match
   (
      () => Error($"'{s}' is not a valid number"),
      (d) => Valid(d)
   );
 
static string Process(string input)
   => input.Split(',')        // Array<string>
      .Map(String.Trim)       // IEnumerable<string>
      .Traverse(Validate)     // Validation<IEnumerable<double>>
      .Map(Enumerable.Sum)    // Validation<double>
      .Match
      (
         errs => string.Join(", ", errs),
         sum => $"The sum is {sum}"
      );

The listing only shows the updated implementation of Process (the top-level statements are the same as previously). If we test this enhanced implementation, we now get this:

Process("1, 2, 3")
// => "The sum is 6"
 
Process("one, two, 3")
// => "'one' is not a valid number, 'two' is not a valid number"

As you can see, in the second example, the validation errors have accumulated as we have traversed the list of inputs. If we used the monadic implementation, TraverseM, we’d only get the first error.

17.1.3 Applying multiple validators to a single value

The preceding example demonstrates how to apply a single validation function to a list of values you want to validate. What about the case in which you have a single value to validate and many validation functions?

We tackled such a scenario in section 9.6.3, where we had a request object to validate and a list of validators, each checking that certain conditions for validity are met. As a reminder, we defined a Validator delegate to capture a function performing validation:

// T => Validation<T>
public delegate Validation<T> Validator<T>(T t);

The challenge was to write a single Validator function combining the validation of a list of Validator’s, harvesting all errors. We had to jump through a few hoops to define a HarvestErrors function with this behavior (listing 9.22).

Now that you know about using Traverse with a Validation-returning function, we can rewrite HarvestErrors much more concisely, as the following listing shows.

Listing 17.6 Aggregating errors from multiple validators

public static Validator<T> HarvestErrors<T>
   (params Validator<T>[] validators)
   => t
   => validators
      .Traverse(validate => validate(t))
      .Map(_ => t);

Here, Traverse returns a Validation<IEnumerable<T>>, collecting all the errors. If there are no errors, the inner value of type IEnumerable<T> will contain as many instances of the input value t as there are validators. The subsequent call to Map disregards this IEnumerable and replaces it with the original object being validated. Here’s an example of using HarvestErrors in practice:

Validator<string> ShouldBeLowerCase
   = s => (s == s.ToLower())
      ? Valid(s)
      : Error($"{s} should be lower case");
 
Validator<string> ShouldBeOfLength(int n)
   => s => (s.Length == n)
      ? Valid(s)
      : Error($"{s} should be of length {n}");
 
Validator<string> ValidateCountryCode
   = HarvestErrors(ShouldBeLowerCase, ShouldBeOfLength(2));
 
ValidateCountryCode("us")
// => Valid(us)
 
ValidateCountryCode("US")
// => Invalid([US should be lower case])
 
ValidateCountryCode("USA")
// => Invalid([USA should be lower case, USA should be of length 2])

17.1.4 Using Traverse with Task to await multiple results

Traverse works with Task much as it does with Validation. We can define TraverseA, which uses the applicative flow and runs all tasks in parallel, TraverseM, which uses the monadic flow and runs the tasks sequentially, and Traverse, which defaults to TraverseA because running independent asynchronous operations in parallel is usually preferable. Given a list of long-running operations, we can use Traverse to obtain a single Task that we can use to await all the results.

In section 16.1.7, we looked at comparing flight fares from two airlines. With Traverse, we’re equipped to deal with a list of airlines. Imagine that each airline’s flights can be queried with a method that returns a Task<IEnumerable<Flight>>, and we want to get all flights available on a given date and route, sorted by price:

interface Airline
{
   Task<IEnumerable<Flight>> Flights
      (string origin, string destination, DateTime departure);
}

How do we get all flights from all the airlines? Notice what happens if we use Map:

IEnumerable<Airline> airlines;
 
IEnumerable<Task<IEnumerable<Flight>>> flights =
   airlines.Map(a => a.Flights(from, to, on));

We end up with an IEnumerable<Task<IEnumerable<Flight>>>. This isn’t at all what we want!

With Traverse, instead, we’ll end up with a Task<IEnumerable<IEnumerable <Flight>>>, a single task that completes when all airlines have been queried (and fails if any query fails). The task’s inner value is a list of lists (one list for each airline), which can then be flattened and sorted to get our list of results sorted by price:

async Task<IEnumerable<Flight>> Search(IEnumerable<Airline> airlines
   , string origin, string dest, DateTime departure)
{
   var flights = await airlines
      .Traverse(a => a.Flights(origin, dest, departure));
   return flights.Flatten().OrderBy(f => f.Price);
}

Flatten is simply a convenience function that calls Bind with the identity function, hence flattening the nested IEnumerable into a single list including flights from all airlines. This list is then sorted by price.

Most of the time, you’ll want the parallel behavior, so I’ve defined Traverse to be the same as TraverseA. On the other hand, if you have 100 tasks and the second task fails, then monadic traverse will save you from running 98 tasks that would still be kicked off when using applicative traverse. So the implementation you choose depends on the use case, and this is the reason for including both.

Let’s look at one final variation on this example. In real life, you probably don’t want your search to fail if one of perhaps dozens of queries to a third-party API fails. Imagine you want to display the best results available, like on many price comparison websites. If a provider’s API is down, results from that provider won’t be available, but we still want to see results from all the others.

The change is easy! We can use the Recover function shown in section 16.1.4 so that each query returns an empty list of flights if the remote query fails:

async Task<IEnumerable<Flight>> Search(IEnumerable<Airline> airlines
   , string origin, string dest, DateTime departure)
{
   var flights = await airlines
      .Traverse(a => a.Flights(origin, dest, departure)
                      .Recover(ex => Enumerable.Empty<Flight>()));
 
   return flights.Flatten().OrderBy(f => f.Price);
}

Here we have a function that queries several APIs in parallel, disregards any failures, and aggregates all the successful results into a single list sorted by price. I find this a great example of how composing core functions like Traverse, Bind, and others allows you to specify rich behavior with little code and effort.

17.1.5 Defining Traverse for single-value structures

So far you’ve seen how to use Traverse with an IEnumerable and a function that returns an Option, Validation, Task, or any other applicative. It turns out that Traverse is even more general. IEnumerable isn’t the only traversable structure out there; you can define Traverse for many of the constructs you’ve seen in the book. If we take a nuts and bolts approach, we can see Traverse as a utility that stacks effects up the opposite way as when performing Map:

Map      : Tr<T>  (T  A<R>)  Tr<A<R>>
Traverse : Tr<T>  (T  A<R>)  A<Tr<R>>

If we have a function that returns an applicative A, Map returns a type with the A on the inside, whereas Traverse returns a type with the A on the outside.

For example, in chapter 8, we had a scenario in which we used Map to combine a Validation-returning function with an Exceptional-returning function. The code went along these lines:

Func<MakeTransfer, Validation<MakeTransfer>> validate;
Func<MakeTransfer, Exceptional<Unit>> save;
 
public Validation<Exceptional<Unit>> Handle(MakeTransfer request)
   => validate(request).Map(save);

What if, for some reason, we wanted to return an Exceptional<Validation<Unit>> instead? Well, now you know the trick: just replace Map with Traverse !

public Exceptional<Validation<Unit>> Handle(MakeTransfer request)
   => validate(request).Traverse(save);

But can we make Validation traversable? The answer is yes. Remember that we can view Option as a list with at most one element. The same goes for Either, Validation, and Exceptional: the success case can be treated as a traversable with a single element; the failure case as empty.

In this scenario, we need a definition of Traverse taking a Validation and an Exceptional-returning function. The following listing shows the implementation.

Listing 17.7 Making Validation traversable

public static Exceptional<Validation<R>> Traverse<T, R>
(
   this Validation<T> valT,
   Func<T, Exceptional<R>> f
)
=> valT.Match
(
   Invalid: errs => Exceptional(Invalid<R>(errs)),
   Valid: t => f(t).Map(Valid)
);

The base case is if the Validation is Invalid; that’s analogous to the empty list case. Here we create a value of the required output type, preserving the validation errors. If the Validation is Valid, that means we should “traverse” the single element it contains, named t. We apply the Exception-returning function f to it to get an Exceptional<R> and then we Map the Valid function on it, which lifts the inner value r into a Validation<R>, giving us the required output type, Exceptional<Validation<R>>.

You can follow this pattern to define Traverse for the other one-or-no-value structures. Notice that once you’ve defined Traverse, then when you have, say, a Validation<Exceptional<T>> and want to reverse the order of the effects, you could just use Traverse with the identity function.

In summary, Traverse is useful not just to handle lists of elevated values but, more generally, whenever you have stacked effects. As you encode your application’s requirements through Option, Validation, and others, Traverse is one of the tools you can use to ensure that the types don’t get the better of you.

17.2 Combining asynchrony and validation (or any other two monadic effects)

Most enterprise applications are distributed and depend on a number of external systems, so much if not most of your code runs asynchronously. If you want to use constructs like Option or Validation, soon enough you’ll deal with Task<Option<T>>, Task<Validation<T>>, Validation<Task<T>>, and so on.

17.2.1 The problem of stacked monads

These nested types can be difficult to work with. When you work within one monad, such as Option, everything is fine because you can use Bind to compose several Option-returning computations. But what if you have a function that returns an Option<Task<T>> and another function of type T Option<R>? How can you combine them? And how would you use an Option<Task<T>> with a function of type T Task<Option<R>>?

We can refer to this more generally as the problem of stacked monads. In order to illustrate this problem and how it can be addressed, let’s revisit one of the examples from chapter 13. The following listing shows a skeletal version of the endpoint that handles API requests to make a transfer.

Listing 17.8 Skeleton of the MakeTransfer command handler

public static void ConfigureMakeTransferEndpoint
(
   WebApplication app,
   Func<Guid, AccountState> getAccount,
   Action<Event> saveAndPublish
)
 
=> app.MapPost("/Transfer/Make", (MakeTransfer cmd) =>   
{
   var account = getAccount(cmd.DebitedAccountId);       
 
   var (evt, newState) = account.Debit(cmd);             
 
   saveAndPublish(evt);                                  
 
   return Ok(new { newState.Balance });                  
});

Handles receiving a command

Retrieves the account

Performs the state transition; returns a tuple with the event and the new state

Persists the event and publishes it to interested parties

Returns information to the client about the new state

The preceding code serves as an outline. Next, you’ll see how to add asynchrony, error handling, and validation.

First, we’ll inject a new dependency to perform validation on the MakeTransfer command. Its type will be Validator<MakeTransfer>, which is a delegate with this signature:

MakeTransfer  Validation<MakeTransfer>

Next, we need to revise the signatures of the existing dependencies. When we call getAccount to retrieve the current state of an account, that operation will hit the database. We want to make it asynchronous, so the result type should be wrapped in a Task. Furthermore, errors can occur when connecting to the DB. Fortunately, Task already captures this. Finally, there’s the possibility that the account doesn’t exist (no events were ever recorded for the given ID), so the result should also be wrapped in an Option. The full signature will be

getAccount : Guid  Task<Option<AccountState>>

Saving and publishing an event should also be asynchronous so that the signature should be

saveAndPublish : Event  Task

Finally, remember that Account.Debit also returns its result wrapped in a Validation:

Account.Debit :
   AccountState  MakeTransfer  Validation<(Event, AccountState)>

Now let’s write a skeleton of the command handler with all these effects in place:

public static void ConfigureMakeTransferEndpoint
(
   WebApplication app,
   Validator<MakeTransfer> validate,
   Func<Guid, Task<Option<AccountState>>> getAccount,
   Func<Event, Task> saveAndPublish
)
=> app.MapPost("/Transfer/Make", (MakeTransfer transfer) =>
{
   Task<Validation<AccountState>> outcome = // ...
 
   return outcome.Map
   (
      Faulted: ex => StatusCode(StatusCodes.Status500InternalServerError),
      Completed: val => val.Match
      (
         Invalid: errs => BadRequest(new { Errors = errs }),
         Valid: newState => Ok(new { Balance = newState.Balance })
      )
   );
});

So far, we’ve listed the dependencies with the new signatures, established that the main workflow will return a Task<Validation<AccountState>> (because there will be some asynchronous operations, and there will be some validation), and mapped its possible states to appropriately populated HTTP responses. Now comes the real work: How do we put together the functions we need to consume?

17.2.2 Reducing the number of effects

First, we’ll need a couple of adapters. Notice that getAccount returns an Option (wrapped in a Task), meaning we should cater for the case in which no account is found. What does it mean if there’s no account? It means the command is incorrectly populated, so we can map None to a Validation with an appropriate error.

LaYumba.Functional defines ToValidation, a natural transformation that “promotes” an Option to a Validation. It maps Some to Valid, using the Option's inner value, and None to Invalid, using the provided Error:

public static Validation<T> ToValidation<T>
   (this Option<T> opt, Error error)
   => opt.Match
   (
      () => Invalid(error),
      (t) => Valid(t)
   );

In the case of getAccount, the returned Option is wrapped in a Task so that we don’t apply ToValidation directly, but use Map instead:

Func<Guid, Task<Option<AccountState>>> getAccount;
 
Func<Guid, Task<Validation<AccountState>>> getAccountVal
   = id => getAccount(id)
      .Map(opt => opt.ToValidation(Errors.UnknownAccountId(id)));

At least now we’re only dealing with two monads: Task and Validation.

Second, saveAndPublish returns a Task, which doesn’t have an inner value, so it won’t compose well. Let’s write an adapter that returns a Task<Unit> instead:

Func<Event, Task> saveAndPublish;
 
Func<Event, Task<Unit>> saveAndPublishF
   = async e =>
   {
      await saveAndPublish(e);
      return Unit();
   };

Let’s look again at the functions we must compose in order to compute the workflow’s outcome:

validate        : MakeTransfer  Validation<MakeTransfer>
getAccountVal   : Guid  Task<Validation<AccountState>>
Account.Debit   : AccountState  MakeTransfer
                   Validation<(Event, AccountState)>
saveAndPublishF : Event  Task<Unit>

If we used Map the whole way through, we’d get a result type of Validation<Task <Validation<Validation<Task<Unit>>>>>. We could try using a sophisticated combination of calls to Traverse to change the order of monads and Bind to flatten them. Honestly, I tried. It took me about half an hour to figure it out, and the result was cryptic and not something you’d be keen to ever refactor!

We have to look for a better way. Ideally, we’d like to write something like this:

from tr in validate(transfer)
from acc in GetAccount(tr.DebitedAccountId)
from result in Account.Debit(acc, tr)
from _ in SaveAndPublish(result.Event)
select result.NewState

We’d then have some underlying implementations of Select and SelectMany that figure out how to combine the types together. Unfortunately, this can’t be achieved in a general enough way: add too many overloads of SelectMany and this will cause overload resolution to fail. The good news is that we can have a close approximation. You’ll see this next.

17.2.3 LINQ expressions with a monad stack

We can implement Bind and the LINQ query pattern for a specific monad stack; in this case, Task<Validation<T>>.3 This allows us to compose several functions that return a Task<Validation<>> within a LINQ expression. With this in mind, we can adapt our existing functions to this type by following these rules:

  • If we have a Task<Validation<T>> (or a function that returns such a type), then there’s nothing to do. That’s the monad we’re working in.

  • If we have a Validation<T>, we can use the Async function to lift it into a Task, obtaining a Task<Validation<T>>.

  • If we have a Task<T>, we can map the Valid function onto it to again obtain Task<Validation<T>>.

  • If we have a Validation<Task<T>>, we can call Traverse with the identity function to swap the containers around.

So our previous query needs to be modified as follows:

from tr in Async(validate(transfer))                
from acc in GetAccount(tr.DebitedAccountId)         
from result in Async(Account.Debit(acc, tr))        
from _ in SaveAndPublish(result.Event).Map(Valid)   
select result.NewState;

Uses Async to lift the Validation into a Task<Validation<>>

GetAccount returns a Task<Validation<>>, which is the monad stack we’re working with.

Uses Map(Valid) to turn a Task into a Task<Validation<>>

This will work as long as the appropriate implementations of Select and SelectMany for Task<Validation<T>> are defined. As you can see, the resulting code is still reasonably clean and easy to understand and refactor. We just had to add a few calls to Async and Map(Valid) to make the types line up. The following listing shows the complete implementation of the command handler, refactored to include asynchrony and validation.

Listing 17.9 The command handler, including asynchrony and validation

public static void ConfigureMakeTransferEndpoint
(
   WebApplication app,
   Validator<MakeTransfer> validate,
   Func<Guid, Task<Option<AccountState>>> getAccount,
   Func<Event, Task> saveAndPublish
)
{
   var getAccountVal = (Guid id) => getAccount(id)
         .Map(opt => opt.ToValidation(Errors.UnknownAccountId(id)));
 
   var saveAndPublishF = async (Event e) 
      => { await saveAndPublish(e); return Unit(); };
 
   app.MapPost("/Transfer/Make", (MakeTransfer transfer) =>
   {
      Task<Validation<AccountState>> outcome =
         from tr in Async(validate(transfer))
         from acc in getAccountVal(tr.DebitedAccountId)
         from result in Async(Account.Debit(acc, tr))
         from _ in saveAndPublishF(result.Event).Map(Valid)
         select result.NewState;
 
      return outcome.Map
      (
         Faulted: ex => StatusCode(StatusCodes.Status500InternalServerError),
         Completed: val => val.Match
         (
            Invalid: errs => BadRequest(new { Errors = errs }),
            Valid: newState => Ok(new { Balance = newState.Balance })
         )
      );
   });
}

Let’s look at the code. First, the operations that need to be completed as part of the workflow are injected as dependencies. Next, we have a couple of adapter functions to go from Option to Validation and from Task to Task<Unit>. We then configure the endpoint that handles transfer requests. Here, we use a LINQ comprehension to combine the different operations in the workflow. Finally, we translate the resulting outcome into an object representing the HTTP response we’d like to return.

As you saw in this chapter, although monads are nice and pleasant to work with in the context of a single monadic type, things get more complicated when you need to combine several monadic effects. Note that this isn’t just the case in C# but even in functional languages. Even in Haskell, where monads are used everywhere, stacked monads are usually dealt with via the rather clunky monad transformers. A more promising approach is called composable effects, and it has first-class support in a rather niche-functional language called Idris. It’s possible that the programming languages of the future will not only have syntax optimized for monads, such as LINQ, but also syntax optimized for monad stacks.

As a practical guideline, remember that combining several monads adds complexity, and limit the nesting of different monads to what you really need. For instance, once we simplified things in the previous example by transforming Option to Validation, we only had to deal with two stacked monads rather than three. Similarly, if you have a Task<Try<T>>, you can probably reduce it to a Task<T> because Task can capture any exceptions raised when running the Try. Finally, if you find yourself always using a stack of two monads, you can write a new type that encapsulates both effects into that single type. For example, Task encapsulates both asynchrony and error handling.

Summary

  • If you have two monads, A and B, you might like to stack them up in values like A<B<T>> to combine the effects of both monads.

  • You can use Traverse to invert the order of monads in the stack.

  • Implementing the LINQ query pattern for such a stack allows you to combine A’s, B’s, and A<B<>>’s with relative ease.

  • Still, stacked monads tend to be cumbersome, so use them sparingly.


1 You may remember from chapter 6 that we could use Bind instead of Map to filter out all the None values and only add up the numbers that were successfully parsed. That’s not desirable for this scenario: we’d be silently removing values that the user probably mistyped in error, effectively giving an incorrect result.

2 The reasons for this were discussed in chapter 6, in the “Why is functor not an interface?” sidebar.

3 I won’t show the implementation, which is included in the code samples. This really is library code, not code that a library user should worry about. You may also ask whether an implementation is required for every stack of monads, and indeed, this is the case, given the pattern-based approach we’ve been following in the book.

..................Content has been hidden....................

You can't read the all page of ebook, please click here login for view all page.
Reset
18.221.231.97