First off, records will be used since they support immutability out of the box.
Maybe.cs
public abstract record Maybe<T>();
public record Nothing<T> : Maybe<T>;
public record UnhandledNothing<T> : Nothing<T>;
public record Something<T>(T Value) : Maybe<T>;
public record Error<T>(Exception CapturedError) : Maybe<T>;
public record UnhandledError<T>(Exception CapturedError) : Error<T>(CapturedError);
As we see, the base record Maybe of T is abstract and can be one of several subtypes. Nothing means there are no value contained inside the 'container' or monad.
Something of T means a value is inside the 'container'. This value can be null, but the container wrapping this value is not null, hence by sticking to monads such as Maybe we avoid null issues for the code that
lives outside using the container, accessing the container. Maybe container here is no magic bullet, but it will make it easier to avoid null issues in your code in the parts where it is used.
Let's next look at extension methods for the Maybe types defined in the records above.
MaybeExtensions.cs
public static class MaybeExtensions
{
/// <summary>ToMayBe operates as a Return function in functional programming, lifting a normal value into a monadic value</summary>
public static Maybe<T> ToMaybe<T>(this T @this)
{
if (!EqualityComparer<T>.Default.Equals(@this, default))
{
return new Something<T>(@this);
}
else if (@this != null && Nullable.GetUnderlyingType(@this.GetType()) == null && @this.GetType().IsPrimitive){
//primitive types that are not nullable and has got a default value are to be considered to have contents and have Something<T>, for example int value 0 or bool value false
return new Something<T>(@this);
}
return new Nothing<T>();
}
/// <summary>TryGetValue is similar to Reduce method in functional programming, but signal a boolean flag if the retrieval of value was successful</summary>
public static bool TryGetValue<T>(this Maybe<T> @this, out T value){
value = @this switch {
Something<T> s => s.Value,
_ => default(T)
};
return @this is Something<T>;
}
//<summary>Call method Bind first to get correct behavior;/summary>
public static Maybe<T> OnSomething<T>(this Maybe<T> @this, Action<T> actionOnSomething){
if (@this is Something<T> s){
actionOnSomething(s.Value);
}
return @this;
}
//<summary>Call method Bind first to get correct behavior;/summary>
public static Maybe<T> OnNothing<T>(this Maybe<T> @this, Action actionOnNothing){
if (@this is UnhandledNothing<T>){
actionOnNothing();
return new Nothing<T>(); //switch from UnhandledNothing<T> to Nothing<T>
}
return @this;
}
//<summary>Call method Bind first to get correct behavior;/summary>
public static Maybe<T> OnError<T>(this Maybe<T> @this, Action<Exception> actionOnError){
if (@this is UnhandledError<T> e){
actionOnError(e.CapturedError);
return new Error<T>(e.CapturedError); //switch from UnhandledError<T> to Error<T>
}
return @this;
}
/// <summary>Bind is similar to Map in functional programming, it applies a function to update and allow switching from TIn to TOut data types, possibly different types</summary>
public static Maybe<TOut> Bind<TIn, TOut>(this Maybe<TIn> @this, Func<TIn, TOut> f){
try
{
Maybe<TOut> updatedMaybe = @this switch {
null => new Error<TOut>(new Exception("Object input is null")),
Something<TIn> s when !EqualityComparer<TIn>.Default.Equals(s.Value, default) => new Something<TOut>(f(s.Value)),
Something<TIn> s when @this.GetType().GetGenericArguments().First().IsPrimitive && Nullable.GetUnderlyingType(@this.GetType()) == null => new Something<TOut>(f(s.Value)),
Something<TIn> _ => new UnhandledNothing<TOut>(),
UnhandledNothing<TIn> _ => new UnhandledNothing<TOut>(),
Nothing<TIn> _ => new Nothing<TOut>(),
UnhandledError<TIn> u => new UnhandledError<TOut>(u.CapturedError),
Error<TIn> e => new Error<TOut>(e.CapturedError),
_ => new Error<TOut>(new Exception($"Got a subtype of Maybe<T>, which is not supported: {@this?.GetType().Name}"))
};
return updatedMaybe;
}
catch (Exception ex)
{
return new UnhandledError<TOut>(ex);
}
}
}
The extension methods above handle the basics for the Maybe of T monad. - We transform from a value of T to the Maybe of T using the method ToMaybe, this method is called Return many places. I think the name ToMaybe is more intuitive for more developers in C#.
- The method TryGetValue is called Reduce many places and extracts the value of Maybe of T if it is available and returns a boolean value if the Maybe of T actually contains a value. If Maybe of T is not the type Something of T it does not hold a value, so knowing this is useful after retrieving the value.
- The method Bind is sometimes called Map and allows updates of the value inside the Maybe of T and also perform transitions of sub types of Maybe of T and uses pattern matching in C#. Bind both maps and also performs the overall flow of state via controlling the sub type of Maybe of T as shown in the pattern specified inside Bind
- The methods OnError, OnNothing, OnSomething are callbacks to do logic when retrieving values of these types that inherit from Maybe of T. Make sure you call the method Bind first
https://github.com/nlkl/Optional/blob/master/src/Optional/Option_Maybe.cs
The benefit with the code in this article is that it is shorter and that it uses records in C#. Since it is shorter, it should be easier to adapt and adjust to the needs. Demo code is next. Consider this record:
Car.cs
public record Car(string Model, string Make, bool? IsFourWheelDrive = null, string? Color = null);
Program.cs
void Main()
{
var something = new Something<string>("hullo");
something = something with { Value = null };
var somethingnull = EqualityComparer<string>.Default.Equals(something.Value, default);
int? nullableNumber = 1;
Maybe<int?> maybe = nullableNumber.ToMaybe();
var volvo = new Car("240 GL", "Volvo");
bool isValueRetrieved = volvo.ToMaybe()
.Bind(car => car with { IsFourWheelDrive = false })
.Bind(car => car with { Color = "Blue" })
.TryGetValue(out Car updatedVolvo);
new { updatedVolvo, isValueRetrieved }.Dump("Updated Volvo");
Maybe<string> noCar = new Something<string>(null)
.Bind(x => x)
.OnNothing(() => Console.WriteLine("The Car is nothing (null)!"));
noCar.Dump("No car");
maybe.Dump("Maybe");
something.Dump();
}
Output from Linqpad shows the result from running the demo code. Note that since a record Car was used in the demo code, inside the Bind calls it was necessary to use the 'with' to mutate the record and create a new record with the necessary adjustments. Also, it is important to call Bind before handlers OnNothing, OnError and OnSomething to work properly in the code, since the transition rules inside Bind should run first to check the correct subtype of Maybe of T. Many implementations of Option of T or Maybe of T that is, also implement operators for equality comparison of the value inside the Maybe of T if there is a defined value there (i.e. Something of T). The mentioned example on Github goes in detail on this and adds several methods for equality comparisons. Also, there are examples of support async methods such as BindAsync in Maybe of T implementations out there. Many of the building blocks of functional programming in C# are not standardized or built into the framework, so working out these building blocks yourself to tailour your source code needs is probably a good solution, if not going for libraries such as LanguageExt.
https://github.com/louthy/language-ext
Finally, notice that in the screenshot below, we have a Value of null for the variable something in the demo code. To get the proper sub type of Maybe of T, we should also call the Bind method. If we instead of :
something.Dump();
Do this:
something.Bind(x => x).Dump();
We get the correct subtype of Maybe of T, UnhandledNothing of T.
And if we also handle the UnhandledNothing of T, we get Nothing of T.
something.Bind(x => x).OnNothing(() => Console.WriteLine("something is null")).Dump();