DEV Community

Cover image for The Monad Invasion - Part 2: Monads in Action!
Guillaume Faas for Vonage

Posted on • Originally published at developer.vonage.com

The Monad Invasion - Part 2: Monads in Action!

Hello friends,

In our previous post "The Monad Invasion - Part 1: What's a Monad?", we chose a pragmatic approach to introduce Monads, incrementally building our own.
By now, you should be familiar with the concept, understanding how to transform its value with either .Map() or .Bind() and how to extract it with .Match().

In this article, we want to demonstrate practical applications for various Monads, and we'll use an example from the Vonage .NET SDK. But first, let's have a quick recap.

Quick Recap

We previously crafted the Optional Monad, which can exist in one of two states: Some, indicating the presence of a value, or None, indicating the absence of value.

private static SchrodingerBox<int> Half(int value) =>
    value % 2 == 0 
        ? SchrodingerBox<int>.Some(value / 2)
        : SchrodingerBox<int>.None();

private static int Increment(int value) => value + 1;

SchrodingerBox<int>.Some(0)
    .Map(Increment) // Some(0) becomes Some(1)
    .Map(Increment) // Some(1) becomes Some(2)
    .Bind(Half) // Some(2) becomes Some(1)
    .Map(Increment) // Some(1) becomes Some(2)
    .Match(some => $"The value is some {some}!", () => "The value is none")
    .Should()
    .Be("The value is some 2!");

SchrodingerBox<int>.Some(0)
    .Map(Increment) // Some(0) becomes Some(1)
    .Bind(Half) // Some(1) becomes None - the Monad's state has changed
    .Map(Increment) // None remains None
    .Match(some => $"The value is some {some}!", () => "The value is none")
    .Should()
    .Be("The value is none");
Enter fullscreen mode Exit fullscreen mode

Our box provides three essential functionalities:

  • .Map(operation) empowers you to generate a new value based on an existing one and wrap the new value into a new box. The operation is only executed if the box is in Some state.
  • .Bind(operation) is a transformation mechanism similar to .Map(). However, it differs by enabling the alteration of the box's state as the function returns a box instead of a value. Like .Map(), the operation is only executed if the box is in Some state.
  • .Match(some, none) evaluates the box's state and invokes the corresponding function.

The box allows you to manipulate a value without knowing its current state.

In essence, the state becomes irrelevant as your sequence of operations remains the same. As you probably observed, the code has no branching constructs (if/else).

When Is It Worth Using A Monad?

Given their nature of opposing states, Monads prove to be irrelevant in scenarios with operations yielding a singular outcome. For Monads to be applicable, the operation must present at least two distinct possible results.

So far, we have worked with the Optional monad, but many more exist. To share examples of other monads, let's explore which monads the library Language-Ext offers and find out their useful contexts.

Option

Given our prior implementation, you should be pretty familiar with this one by now. It shines when wrapping an optional value or a value that may (or may not) exist.

private async Task<Option<User>> FindUser(Guid id)
{
    var user = await this.repository.Users.FirstOrDefaultAsync(user => user.Id == id);
    return user ?? Option<User>.None;
}
Enter fullscreen mode Exit fullscreen mode

Try

The Try<T> Monad represents an operation that may encounter failure:

  • Success indicates the operation succeeded, yielding the result <T>.
  • Exception indicates the operation resulted in an exception. The Monad will return the exception instead of throwing it.
private static Try<decimal> Divide(decimal value, decimal divisor) => 
        Try(() => value / divisor);
Enter fullscreen mode Exit fullscreen mode

In the above example, we can encapsulate a potentially "risky" operation within a Try.

  • Invoking Divide(50,2) will return a Success state with the value 25.
  • Invoking Divide(50,0) will return a Exception state with a DivideByZeroException.

Either

The Either<L, R> Monad represents an operation that can return two distinct types, defined by Leftor Right. This Monad is extremely versatile, given <L, R> are both generics types. Nevertheless, the Left state usually represents errors or exceptional cases.

private static Either<Error, decimal> Divide(decimal value, decimal divisor) =>
    divisor == 0
        ? new Error("Cannot divide by 0.")
        : value / divisor;

private record Error(string Message);
Enter fullscreen mode Exit fullscreen mode

In line with the previous scenario:

  • Invoking Divide(50,2) will return a Right state with the value 25.
  • Invoking Divide(50,0) will return a Left state with an Error record explaining why it wasn't processed.

Validation

The Validation<Fail, Success> Monad represents a validation operation that may fail for multiple reasons.

private record User(string Firstname, string Lastname, MailAddress Email);

private static Validation<string, User> CreateUser(string firstname, string lastname, string email)
{
    var errors = new Seq<string>();
    if (string.IsNullOrWhiteSpace(firstname))
    {
        errors = errors.Add("Firstname cannot be empty.");
    }

    if (string.IsNullOrWhiteSpace(lastname))
    {
        errors = errors.Add("Lastname cannot be empty.");
    }

    if (!MailAddress.TryCreate(email, out var address))
    {
        errors = errors.Add("Invalid mail address.");
    }

    return errors.Any()
        ? Validation<string, User>.Fail(errors)
        : Validation<string, User>.Success(new User(firstname, lastname, address));
}
Enter fullscreen mode Exit fullscreen mode

In this example:

  • Invoking CreateUser("Jane", "Doe", "jane.doe@email.com") will return a Success with a valid User instance.
  • Invoking CreateUser(null, null, null) (or any invalid value) will return a Failure with the collection of encountered errors.

Referential Transparency

These Monads all have something in common: they're all transparent about an operation's potential outcomes, whether it can fail, throw an exception or return various values.
As such, the method's return type must communicate the result of those outcomes instead of an invisible and unpredictable mechanism (an exception).

Here, we're talking about Referential Transparency. It applies when we can always replace an expression by its value.

// Referentially Opaque examples
public User FindUser(Guid id) => this.users.First(user => user.Id == id);
public int Divide(int a, int b) => a/b;
public void SetName(string name)
{
    ArgumentNullException.ThrowIfNull(name);
    this.Name = name;
}

// Referentially Transparent examples
public Maybe<User> FindUser(Guid id) => this.users.FirstOrDefault(user => user.Id == id) ?? Maybe<User>.None;
public Try<decimal> Divide(decimal value, decimal divisor) => Try(() => value / divisor);
public int Add(int a, int b) => a+b;
public Either<Error, Unit> SetName(string name)
{
    if (name is null)
    {
        return new Error("Name cannot be null);
    }

    this.Name = name;
    return Unit.Value;
}
Enter fullscreen mode Exit fullscreen mode

You probably noticed that .SetName() returns a Either<Error, Unit>. You may have come across Unit in libraries like MediatR or Language-Ext. It's a simple construct representing a type with only one possible value. We use it as a placeholder for operations that do not return a value but may return another state. In our example, .SetName() is a Command that does not return a value but may fail. Therefore, the monad Either<Error, Unit> carries two possible states: Right (without value) or Left (with an Error).

Although Referential Transparency doesn't inherently solve specific problems, it significantly improves code predictability and readability. Making everything explicit in the method's signature minimizes the potential for the unexpected.
Uncle Bob emphasizes in his book "Clean Code: A Handbook of Agile Software Craftsmanship" that "the ratio of time spent reading versus writing is well over 10 to 1. We are constantly reading old code as part of the effort to write new code... Therefore, making it easy to read makes it easy to write". This highlights the importance of code clarity and transparency in facilitating both comprehension and efficient development.

This was my primary motivation behind the making of "Throw exceptions... out of your codebase".

Vonage SDK Example

So far, we focused on relatively simple examples. However, what about using Monads in more complex flows? Let's look at an example from our .NET SDK involving our two-factor authentication API Verify.

Custom Monads

Previously, we built an Optional and showed an existing set based on Language-Ext, but various libraries also offer Monad implementations. In the case of our SDK, we deliberately avoided relying on external libraries like Language-Ext. Indeed, Monads are a part of the SDK's public API, and relying on an external library would introduce a dependency over which we would have limited control.

Our approach involved creating our custom Monad implementations tailored to the specific needs of the SDK. This strategy allowed us to maintain control over the design and functionality of these Monads while avoiding dependencies on external libraries.

Furthermore, our goal was to present a lightweight and user-friendly version of specific Monads, ensuring easier adoption from developers working with our SDK.

Our SDK implements the following Monads:

  • Result<T> Monad, similar to an Either<IFailure, T> with a more concise syntax - C# being verbose regarding generics.
  • Maybe<T> Monad, similar to an Option<T>.

Example With Two-Factor Authentication

2FA is a two-step workflow. We first initiate an authentication process, resulting in the customer receiving a validation code based on the specified workflow (SMS, WhatsApp, Email, Voice, SilentAuth). Once the code is received, we send it to our API for verification.

var myPhoneNumber = ...
var result = await StartVerificationRequest.Build()
    .WithBrand("Monads Inc.")
    .WithWorkflow(SmsWorkflow.Parse(myPhoneNumber))
    .Create() // Build the authentication request
    .BindAsync(request => verifyClient.StartVerificationAsync(request)) // Process the authentication request
    .BindAsync(VerifyCodeAsync) // Start the second step if it's a success
    .Match(AuthenticationSuccessful, AuthenticationFailed);

private async Task<Result<Unit>> VerifyCodeAsync(StartVerificationResponse response)
{
    var myCode = ... // Receive verification code based on the specified workflow
    return await response
        .BuildVerificationRequest(myCode) // Build the verification request
        .BindAsync(request => verifyClient.VerifyCodeAsync(request)); // Process the verification request
}
Enter fullscreen mode Exit fullscreen mode

Everything we've covered so far remains applicable as our workflow contains fetching user input, parsing operations, and making API calls.

Once again, we successfully removed branching from our code, maintaining a consistent flow regardless of the Monad's state. It's worth noting our process can fail at five distinct locations:

  • When building the authentication request
  • When processing the authentication request
  • When building the verification request
  • When processing the verification request
  • When calling the second step VerifyCodeAsync

If you remember, in our previous post, we talked about keeping a Monad active as long as possible. In this scenario, the value remains in our Result<T> from the beginning until the end of the flow, allowing us to chain the entire sequence of operations.

To Be Or Not To Be... Pure?

In our previous example, you may have noticed that our Monad did not remain pure during the workflow. Indeed, we provided unpure functions to .Bind(), like request => verifyClient.StartVerificationAsync(request) or request => verifyClient.VerifyCodeAsync(request). Is it problematic, though?

By definition, a pure function does not refer to any global state and should not produce any side effect. It consistently produces an output that solely depends on the input, ensuring the same output for a specific input - in other words, high predictability.

While the concept of "pure monad" might be discussed, it's essential to understand that using monads does not necessarily require the Monad itself to be pure. Instead, monads are often used to structure computations involving impure operations while adhering to functional programming principles. Indeed, interactions with external resources, such as databases or APIs, introduce side effects.

Impure Monads... With Exceptions?

The default behaviour ensures that our monads won't throw any exception, even if the parameter function within them does. This design choice helps us maintain referential transparency.

public Result<TB> Bind<TB>(Func<T, Result<TB>> bind)
{
    try
    {
        return this.IsFailure
            ? Result<TB>.FromFailure(this.failure)
            : bind(this.success);
    }
    catch (Exception exception)
    {
        return SystemFailure.FromException(exception).ToResult<TB>();
    }
}

public Result<TB> Map<TB>(Func<T, TB> map)
{
    try
    {
        return this.IsFailure
            ? Result<TB>.FromFailure(this.failure)
            : Result<TB>.FromSuccess(map(this.success));
    }
    catch (Exception exception)
    {
        return SystemFailure.FromException(exception).ToResult<TB>();
    }
}
Enter fullscreen mode Exit fullscreen mode

However, what if you prefer to stick with exceptions instead of extracting the value with .Match()? We wanted our monads to be versatile, so we introduced a GetSuccessUnsafe() functionality. This function will throw an exception if the monad is in the Failure state.

public T GetSuccessUnsafe() => this.IfFailure(value => throw value.ToException());
Enter fullscreen mode Exit fullscreen mode

The type and data of the exception depend on the underlying failure value:

  • ResultFailure will throw a VonageException
  • ParsingFailure will throw a VonageException
  • HttpFailure will throw a VonageHttpRequestException
  • AuthenticationFailure will throw a VonageAuthenticationException
  • And so on...

Here's how you can incorporate exceptions into your monadic flow using the same example as before:

try
{
    await StartVerificationRequest.Build()
        .WithBrand("Monads Inc.")
        .WithWorkflow(SmsWorkflow.Parse(myPhoneNumber))
        .Create()
        .BindAsync(request => verifyClient.StartVerificationAsync(request))
        .BindAsync(VerifyCodeAsync)
        .GetSuccessUnsafe(); // Will throw exception if in the Failure state
    AuthenticationSuccessful();
}
catch (Exception exception)
{
    AuthenticationFailed(exception);
}
Enter fullscreen mode Exit fullscreen mode

Whether you choose the standard monadic approach or opt for exceptions, our goal is to accommodate different styles of error handling, ensuring that our monads align with your coding preferences.

Wrapping Up

We've concluded the second post of our "The Monads Invasion" series. This article aimed to demonstrate various sets of Monads, including our custom implementations, and illustrate their use in extended workflows.

At this point, you should realize Monads offers an alternative approach to error handling in your workflows - and that's no coincidence. Indeed, our upcoming post will bring more light on the methodology behind the chaining of operations. Stay tuned for more!

If you have any questions or want to chat, feel free to hit me up on my LinkedIn or join us on the Vonage Developer Slack.

Happy coding, and I'll catch you later!

Top comments (0)