Unveiling the power of the Maybe type: a 
comprehensive primer

Unveiling the power of the Maybe type: a comprehensive primer

The concept of null was introduced by Tony Hoare as part of his work on the ALGOL 60 language design in around 1965. Speaking at a software conference in 2009, he went on to acknowledge it as his billion dollar mistake .

Introduction

In this post, we'll examine some of Maybe's practical applications for managing optional values. We will be using a functional library called CSharpFunctionalExtensions since C# lacks a native implementation. Subsequent posts will delve into the reasons behind using these types and we will look to hand craft our own implementation. This exploration aims to deepen our understanding of the inner workings of these types and the support the C# language offers when working with them. With that foundation laid, let's shift our focus to the Maybe type and how it prevents NullReference exceptions within our codebase.

A Maybe type is used to indicate the possibility of a missing value, manifesting in two distinct states:

  1. The Some state, denoting the presence of a value.

  2. The None state, indicating the absence of a value.

To create a Maybe instance, you use the From factory method in the type.

Maybe<decimal> price = Maybe.From(3.45m);

To denote an missing price, utilise the None static property.

Maybe<decimal> missingPrice = Maybe<decimal>.None;

A more concrete example.

var products = new Dictionary<string, Product>
{
    { "SKU123", new Product("SKU123", "Banana", 3.45) },
    { "SKU456", new Product("SKU456", "Yoghurt", 4.80) },
    { "SKU789", new Product("SKU789", "Milk", 5.60) },
    { "SKU101", new Product("SKU101", "Muffins 2 Pack", 4.40) }
};

// a item with this SKU exist in the dictionary and returns
// the Some type
Maybe<Product> banana = products.GetMaybe("SKU123");

// this item does not exist in the dictionary and we return 
// the None variant of the Maybe type.
Maybe<Product> apple = products.GetMaybe("SKU120");

One implementation for the GetMaybe extension method could be as follows.

public static class DictionaryExtensions
{
    public static Maybe<TValue> GetMaybe<TKey, TValue>(
      this Dictionary<TKey, TValue> dictionary, TKey key) =>
        dictionary.TryGetValue(key, out var value)
        // when the value is present, return the Some variant
        ? Maybe<TValue>.From(value)
         // otherwise return the None variant
        : Maybe<TValue>.None
}

How does this help us?

Maybe<Product> product = await GetProductBySku("SKU123");

public Task<Maybe<Product>> GetProductBySku(string sku) {...}

Designating the return type of the function as Maybe<Product> instead of just Product explicitly indicates the possibility of a missing value. This concept aligns with nullable value types (e.g., int?) and the more recent features of non-nullable reference types, though with additional capabilities that we'll explore further. This approach compels the consuming code to account for scenarios where the value might be missing, a consideration that is often overlooked and can result in the well-known null reference exception.

Performing operations on a Maybe type

Accessing the value of a Maybe type is typically performed through one of the extension methods provided by the type, and it is recommended to avoid direct value access.

Maybe<Product> banana = products.GetMaybe("SKU123"); 
Maybe<decimal> discountedPrice = banana.Select(b => b.Price * 0.9)
// apply a 10% discount

Let's unpack what is going here:

  • The Select method on the Maybe type executes the provided lambda if a Product matching that SKU exist.

  • In the case where no Product is present, the method returns the None variant, indicating the absence of a valid value.

We can continue extending the chain of computations in the above sequence, allowing for additional operations without the need to stop at just one.

Maybe<string> priceLabel =  products.GetMaybe("SKU123") 
    .Select(b => b.Price * 0.9) 
    .Select(b => $"{p.Name} at {p.Price}$")

Unpacking the value from a Maybe type

We can think of the Maybe as a container type wrapping any value given to it, similar to how a List or an Array is a container type for a collection of value.

3.54m |> Maybe.From = Maybe(3.54m)

We retain the value within the container type for as long as possible, and only consider extraction when reaching the system's boundaries, where the value may need to be stored or send to an external client.

[Route("/products/{sku}")]
public Task<IHttpActionResult> GetProductBySku(string sku)
{
    Maybe<Product> product = await GetProductBySku(sku);
    var response = product.HasValue
        ? Ok(product.Value) as IHttpActionResult
        : NotFound()
}

We can employ pattern matching on the Maybe type using the switch expression. The previous example can be reformulated as follows:

Maybe<Product> product = await GetProductBySku(sku);
var response = product switch {
    {HasValue: true, Value: var p} => Ok(p) as IHttpActionResult,
    _ => NotFound(),
};

Combining Maybe types

Lets look at a slightly more involved example where we combine multiple Maybe types.

Consider a scenario where we are developing an API to parse configuration data from a JSON file:

{
    "trolleyApi": {
        "url": "http://api.trolley.local",
        "timeOutInMilliSeconds": 1000
    }
}

Our goal is to map this JSON configuration to a strongly typed C# class:

public class ApiConfig 
{
    public required string Url {get; init;}
    public required int TimeOutInMilliSeconds {get; init;}
}

Now .NET provides a reflection based method that binds and returns the specified type.

var apiConfig = Configuration.GetSection("trolleyApi").Get<ApiConfig>();

However, this approach poses a potential risk of null reference exceptions in our codebase. If the Url field is missing, the binder sets it to null, and if the timeout property is missing, it defaults to default(int). To address these issues, we'll explore using the Maybe type. We'll parse individual values and then combine them to create an instance of the ApiConfig type. Let's begin by defining some extension methods for the IConfiguration interface.

public static class MaybeExtensions
{
    public static Maybe<string> GetString(
        this IConfiguration configuration, string key)
    {
        string? value = configuration[key];
        return value == null
           ? Maybe<string>.None
           : Maybe<string>.From(value);
    }
}

This enables us to retrieve the URL configuration as follows:

Maybe<string> url = configuration.GetString("apiConfig:url");

Similarly, we'll introduce a separate extension method for parsing the timeout property:

Maybe<int> timeoutInMilliSeconds = 
   configuration.GetInt("apiConfig:timeOutInMilliSeconds");

One possible implementation of the GetInt method could be as follows, but there's room for improvement:

public static Maybe<int> GetInt(
   this IConfiguration configuration, string key)
{
    Maybe<string> intString = configuration.GetString(key);
    if (intString.HasNoValue)
    {
        return Maybe<int>.None;
    }
    if (int.TryParse( intString.Value, result: out var value))
    {
        return Maybe<int>.From(value);
    }
    else 
        return Maybe<int>.None;

}

Let's enhance this by extracting the logic for parsing an integer from a string into a separate method and then explore how the two can be effectively combined:

public static Maybe<int> ParseInt(this string value) => 
    int.TryParse(value, result: out var intValue) 
    ? Maybe<int>.From(intValue) 
    : Maybe<int>.None;

public static Maybe<int> GetInt(
   this IConfiguration configuration, string key)
{
    Maybe<string> intString = configuration.GetString(key);
    if (intString.HasNoValue) return Maybe<int>.None;
    return intString.Value.ParseInt();
}

While this is a step forward, a more elegant solution exists for combining two Maybe types. This pattern becomes particularly valuable as we delve into Result types in the subsequent discussions.

public static Maybe<int> GetInt(
    this IConfiguration configuration, string key)
{
    Maybe<string> intString = configuration.GetString(key);
    return intString.SelectMany(s => ParseInt(s));
}

Select vs SelectMany

The Maybe type provides a SelectMany method (also known as Bind), allowing the combination of a Maybe type with another function that returns a Maybe type. Similar to the Select method (also known as Map), SelectMany verifies the presence of a value before executing the provided lambda function. The key distinction lies in the fact that Select takes a function returning a normal type, whereas SelectMany takes another function returning another Maybe type.

var discountedPrice = products.GetMaybe("SKU123") 
    .Select(b => b.Price * 0.9)

Both Select and SelectMany methods are familiar from the LINQ feature in C#, and this is intentional. This allows us to write the above using the from syntax.

public static Maybe<int> GetInt(
   this IConfiguration configuration, string key)
{
    var value = from str in configuration.GetString(key)
                from parsedInt in ParseInt(s)
                select parsedInt;
    return value;
}

We start with a from statement, and each subsequent SelectMany method is substituted with another from statement.

In a similar fashion, our initial examples can be converted to their from equivalents:

Maybe<string> priceLabel =  products.GetMaybe("SKU123") 
    .Select(b => b.Price * 0.9) 
    .Select(b => $"{p.Name} at {p.Price}$")

to

Maybe<string> priceLabel =  
    from product in products.GetMaybe("SKU123") 
    let discountedPrice = product.Price * 0.0
    select $"{product.Name} at {discountedPrice}$";

As there can be only one select keyword in a from-based expression, all Select methods preceding the last one will be transformed to utilize the let keyword.

The choice between using the from syntax or sticking with the SelectMany extension methods is a matter of personal preference. For the example above, either option is viable. However, there are benefits to adopting the from syntax, which we'll explore as we progress through this exercise.

Applying what we've learned so far, we can complete the exercise we set out to do:

Maybe<ApiConfig> apiConfig =
 from url in configuration.GetString("apiConfig:url")
 from timeout in configuration.GetInt("apiConfig:timeOutInMilliSeconds")
 select new ApiConfig(url, timeout);

Despite the progress, there are still some issues with our approach. We either obtain a valid ApiConfig instance or a None instance. It would be beneficial to know which property (or properties) were missing in the appsettings.json file. We'll address this in the next post when introducing the Result type.

We covered quite a bit of ground till now. Let's reflect on the advantages of this style of programming:

  1. Our code becomes more expressive.

  2. We avoid nested if statements that would otherwise be present.

  3. By sticking with Select and SelectMany methods, we gain safety from null reference exceptions.

Some practical applications of the Maybe type

The Maybe type becomes valuable whenever there's a need to signify the potential absence of a value. Here are some concrete examples to whet your appetite.

Denoting optional values when designing a type.

public class PersonName
{
    public required string FirstName {get; init;}
    public Maybe<string> MiddleName {get; init;}
    public required string LastName {get; init;}
}

This clearly indicates that the first and last name properties are mandatory, while the middle name is optional.

var person = new PersonName
{
    FirstName = "John",
    MiddleName = Maybe<string>.None,
    LastName = "Doe"
};

This spares us from devising placeholder values for optional properties, often represented by null or other variants such as default or string.empty.

Reading values from external systems

Frequently, when retrieving a configuration value or a record from a database, there's a chance of the value being absent in the external storage. This scenario is aptly captured by a method returning a Maybe type. We've observed instances of this with a Maybe-backed configuration API and in examples where we fetch a product by its ID (or SKU) from a datastore.

int timeout = 
  configuration.GetConfig<int>("timeoutInMilliSeconds").Unwrap(2000);

Here the GetConfig extension method has a method signature of taking a string as input and returning a Maybe type of the generic parameter T.

public Maybe<T> GetConfig(this Configuration config, string key) { ... }

Here we are showcasing an example of the consumer unwrapping this configuration with a default value (2000) in the event that the value is absent in the configuration file.

Constructing fluent APIs around methods that provide a Try* variation.

We've previously encountered examples where we transformed int.TryParse and Dictionary.TryGet into their Maybe equivalents. These Maybe versions offer increased composability with other Maybe types and can be effortlessly converted into a Result type, if needed. Numerous methods within the Base Class Library (BCL) incorporate a Try* variation, making them suitable candidates for representation via a Maybe equivalent. Similar patterns emerge in application-specific domain code, providing opportunities to leverage the Maybe type.

public CustomerCard TryGetGiftCard(IEnumerable<CustomerCard> cards) => 
  cards.FirstOrDefault(c => c.CardType == CardType.GiftCard);

The above can be updated to:

public Maybe<CustomerCard> GetGiftCard(IEnumerable<CustomerCard> cards)
 => Maybe.From(cards.FirstOrDefault(c => c.CardType == CardType.GiftCard);

Alternatively, there is a more convenient TryFirst method for working with collections that achieves the same result.

public Maybe<CustomerCard> GetGiftCard(IEnumerable<CustomerCard> cards)
  => cards.TryFirst(c => c.CardType == CardType.GiftCard);

And thats a wrap...

In conclusion, the Maybe type offers a clean and expressive solution to the age-old challenge of handling optional values. By embracing Maybe, we can steer clear of null-related pitfalls and better communicate our intend to consumers of our code. As we bid farewell to this primer on the Maybe type, remember that adopting Maybe not only promotes robust and reliable code but also marks a step towards a more functional and expressive coding paradigm. Stay tuned to the next post of Result type, offering us the ability to write safer and even more expressive code. Until then, Happy coding!