Monads sound fancy, but sometimes they’re actually really simple and useful. Today we’ll look at the Maybe monad, which is a low-overhead tool that’s extremely useful to prevent bugs.

It’s very common for a function to return an error code like -1 or null. The caller of this function may or may not check the return value to see if the error code was returned. Various bugs occur when the caller doesn’t check and uses the returned error code. For -1, the error might be that a value is unexpectedly negative. For null, the error might be a NullReferenceException being thrown.

As an example, consider a function to compute a square root:

public static double MySqrt(double val)
{
    return Math.Sqrt(val);
}

If the parameter passed is negative or NaN, Math.Sqrt returns NaN. If the parameter passed is positive infinity, Math.Sqrt returns positive infinity. Now let’s look at a caller of this function:

void ShowScore(double x)
{
    guiText.text = "Score: " + MySqrt(x);
}

If the x parameter happens to be negative, for example, then the computed score will be NaN and so the GUI will show Score: NaN which is clearly a bad experience for the player.

Now let’s rewrite MySqrt using the Maybe monad which we’ll later create:

public static Maybe<double> Sqrt(double val)
{
    double sqrt = Sqrt(val);
    return !double.IsNaN(sqrt) && !double.IsPositiveInfinity(sqrt)
        ? sqrt
        : Maybe<double>.Nothing;
}

Two changes were made here. First, we return a Maybe<double> instead of a double. This signals to callers that the function may fail by telling them that we’ll only maybe return a valid double, not all the time. Second, we return a valid Maybe<double> with the computed double when Math.Sqrt succeeds and Nothing when Math.Sqrt fails.

Now let’s look at the caller:

void ShowScore(double x)
{
    guiText.text = "Score: " + MySqrt(x).Value;
}

All we needed to change here is to explicitly get the returned value from with .Value.

It seems at this point like we’ve just introduced some extra typing and that ShowScore is still ignoring the possibility of using an error code return value. There’s more warning in the name Maybe and requirement to work around it by typing .Value, but both may be ignored and cause errors.

The major opportunity here is to implement error checking into the Value property. Maybe can keep track of whether a value has been set and Value can check that flag to take action when no value is set. Here’s the general strategy:

public struct Maybe<T>
{
    private bool hasValue;
    private T value;
 
    public Maybe(T value)
    {
        hasValue = true;
        this.value = value;
    }
 
    public T Value
    {
        get
        {
            Assert.IsTrue(hasValue, "Can't get Maybe<T> value when not set");
            return value;
        }
    }
 
    public static Maybe<T> Nothing
    {
        get
        {
            return default(Maybe<T>);
        }
    }
}

So when assertions are enabled in debug builds of the game then calling .Value will result in an assertion if a value wasn’t passed to the constructor and no assertion if a value was passed to the constructor. In release versions of the game where assertions are disabled, the assertion is removed and the value is simply returned regardless of whether a value was passed to the constructor or not.

At this point it’s worth noting that Maybe<T> is very similar to Nullable<T> and its syntactic sugar version T?. The key difference is that Nullable<T> throws exceptions in both debug and release builds while Maybe<T> uses debug-only assertions. This means that Maybe<T> has very little overhead in release builds.

To understand better what the Maybe<T>.Value property returns, let’s look at all four possible runtime configurations:

Debug Release
Has Value Value Value
No Value Assertion default(T)

In comparison, Nullable<T>.Value behaves like this:

Debug Release
Has Value Value Value
No Value Exception Exception

Both Maybe and Nullable are implementations of the maybe promise, but Maybe provides an option that uses assertions instead of exceptions for those so inclined.

Now let's flesh out the Maybe type:

using UnityEngine.Assertions;
 
/// <summary>
/// The "maybe" monad, enforced by Unity assertions
/// </summary>
/// 
/// <example>
/// Create one with a value like this:
/// <code>
///   Maybe{int} maybe = new Maybe{int}(123);
/// </code>
/// 
/// Create one without a value like this:
/// <code>
///   Maybe{int} maybe = Maybe{int}.Nothing;
/// </code>
/// </example>
/// 
/// <author>
/// Jackson Dunstan, https://JacksonDunstan.com/articles/4930
/// </author>
/// 
/// <license>
/// MIT
/// </license>
public struct Maybe<T>
{
    /// <summary>
    /// If the value is set. True when the constructor is called. False
    /// otherwise, such as when `default(T)` is called.
    /// </summary>
    private bool hasValue;
 
    /// <summary>
    /// The value passed to the constructor or `default(T)` otherwise
    /// </summary>
    private T value;
 
    /// <summary>
    /// Create the <see cref="Maybe{T}"/> with a set value
    /// </summary>
    /// 
    /// <param name="value">
    /// Value to set
    /// </param>
    public Maybe(T value)
    {
        this.value = value;
        hasValue = true;
    }
 
    /// <summary>
    /// Create a <see cref="Maybe{T}"/> with no set value
    /// </summary>
    /// 
    /// <value>
    /// A <see cref="Maybe{T}"/> with no set value
    /// </value>
    public static Maybe<T> Nothing
    {
        get
        {
            return default(Maybe<T>);
        }
    }
 
    /// <summary>
    /// Convert a value to a a <see cref="Maybe{T}"/>
    /// </summary>
    /// 
    /// <returns>
    /// The created <see cref="Maybe{T}"/> with the converted value set
    /// </returns>
    /// 
    /// <param name="value">
    /// Value to convert
    /// </param>
    public static implicit operator Maybe<T>(T value)
    {
        return new Maybe<T>(value);
    }
 
    /// <summary>
    /// Convert the <see cref="Maybe{T}"/> to its value. Throws an exception to
    /// ensure that the value is set.
    /// </summary>
    /// 
    /// <returns>
    /// The value of the converted <see cref="Maybe{T}"/>
    /// </returns>
    /// 
    /// <param name="maybe">
    /// The <see cref="Maybe{T}"/> to get the value of
    /// </param>
    public static explicit operator T(Maybe<T> maybe)
    {
        Assert.IsTrue(maybe.hasValue, "Can't convert Maybe<T> to T when not set");
        return maybe.Value;
    }
 
    /// <summary>
    /// Convert a <see cref="Maybe{T}"/> to a bool by returning whether it's set
    /// </summary>
    /// 
    /// <returns>
    /// If the <see cref="Maybe{T}"/> has a set value
    /// </returns>
    /// 
    /// <param name="maybe">
    /// The <see cref="Maybe{T}"/> to convert
    /// </param>
    public static implicit operator bool(Maybe<T> maybe)
    {
        return maybe.hasValue;
    }
 
    /// <summary>
    /// Convert a <see cref="Maybe{T}"/> to a bool by returning whether it's set
    /// </summary>
    /// 
    /// <returns>
    /// If the <see cref="Maybe{T}"/> has a set value
    /// </returns>
    /// 
    /// <param name="maybe">
    /// The <see cref="Maybe{T}"/> to convert
    /// </param>
    public static bool operator true(Maybe<T> maybe)
    {
        return maybe.hasValue;
    }
 
    /// <summary>
    /// Convert a <see cref="Maybe{T}"/> to a bool by returning whether it's
    /// <i>not</i> set
    /// </summary>
    /// 
    /// <returns>
    /// If the <see cref="Maybe{T}"/> does <i>not</i> have a set value
    /// </returns>
    /// 
    /// <param name="maybe">
    /// The <see cref="Maybe{T}"/> to convert
    /// </param>
    public static bool operator false(Maybe<T> maybe)
    {
        return !maybe.hasValue;
    }
 
    /// <summary>
    /// Whether a value is set
    /// </summary>
    /// 
    /// <value>
    /// If a value is set, i.e. by the constructor
    /// </value>
    public bool HasValue
    {
        get
        {
            return hasValue;
        }
    }
 
    /// <summary>
    /// Get the value passed to the construtor or assert if the constructor was
    /// not called, e.g. by creating with `default(T)`.
    /// </summary>
    /// 
    /// <value>
    /// The set value
    /// </value>
    public T Value
    {
        get
        {
            Assert.IsTrue(hasValue, "Can't get Maybe<T> value when not set");
            return value;
        }
    }
 
    /// <summary>
    /// Get the value passed to the construtor or `default(T)` if the
    /// constructor was not called, e.g. by creating with `default(T)`.
    /// </summary>
    /// 
    /// <returns>
    /// The value if set or `default(T)` if not set
    /// </returns>
    public T GetValueOrDefault()
    {
        return hasValue ? value : default(T);
    }
 
    /// <summary>
    /// Get the value passed to the construtor or the parameter if the
    /// constructor was not called, e.g. by creating with `default(T)`.
    /// </summary>
    /// 
    /// <returns>
    /// The value if set or the parameter if not set
    /// </returns>
    /// 
    /// <param name="defaultValue">
    /// Value to return if the value isn't set
    /// </param>
    public T GetValueOrDefault(T defaultValue)
    {
        return hasValue ? value : defaultValue;
    }
}

The above code expands on the basics by adding a lot of convenience features and commenting. Here's what they're all used for:

// Convert a T to a Maybe<T>:
// public static implicit operator Maybe<T>(T value)
Maybe<int> maybeInt = 123;
 
// Get the value from a Maybe<T> by casting:
// public static explicit operator T(Maybe<T> maybe)
int i = (int)maybeInt;
 
// Treat a Maybe<T> like a bool:
// public static implicit operator bool(Maybe<T> maybe)
// public static bool operator true(Maybe<T> maybe)
// public static bool operator false(Maybe<T> maybe)
if (maybeInt)
{
    print("maybeInt has the value: " + maybeInt.Value);
}
 
// Check if the Maybe<T> has a value:
// public bool HasValue
if (maybeInt.HasValue)
{
    print("maybeInt has the value: " + maybeInt.Value);
}
 
// Get the value if set or default(T) if not:
// public T GetValueOrDefault()
int i2 = maybeInt.GetValueOrDefault(); // 123
int i3 = Maybe<int>.Nothing.GetValueOrDefault(); // 0
 
// Get the value if set or the parameter if not:
// public T GetValueOrDefault(T defaultValue)
int i4 = maybeInt.GetValueOrDefault(456); // 123
int i5 = Maybe<int>.Nothing.GetValueOrDefault(456); // 456

Next time you're considering returning an error code, be it -1, null, string.Empty, or anything else, consider using a Maybe<T> or instead. You'll express to callers that you might not return a valid value and you'll catch issues in development when callers accidentally try to use the invalid value before they become real bugs in shipping code.