Null considered harmful
The problem with null has been discussed at length. With the newer versions of C# the compiler is capable of helping us keep track of reference variables, which may or may not be null, but it isn't bulletproof. This is a problem that other languages have never had, so how do they deal with the possibility that some data isn't present?
In dynamic languages like JavaScript, sometimes the idea is punted entirely, and not only can null be found, but undefined as well. In statically typed languages like Haskell and F#, they use generic Maybe (Option in F#) types. Can we have these types in C#? How would we implement and use them?
A List of 0 or 1
One way to think of a Maybe type is to think of it as a list, which will either have 1 item of the generic type, or 0. Implemented this way, it can be worked with like any other IEnumerable in C#.
public class Maybe<Item> : IEnumerable<Item>
{
private readonly Item[] _array;
public Maybe(Item item)
{
if (item is null) _array = new Item[0];
else _array = new Item[] { item };
}
public Maybe() => _array = new Item[0];
public IEnumerator<Item> GetEnumerator() => _array.GetEnumerator();
public IEnumerator IEnumerable.GetEnumerator() => GetEnumerator();
}
This implementation takes advantage of the fact that lists can always be used in a null safe way, since an empty list is a valid list. Anyone familiar with LINQ will be able work with such an implementation. What this implementation lacks is intent. There's nothing about it's external API which indicates that there will either be 0 or 1 items. You can also get right back to null with a call to SingleOrDefault, or throw an exception if you call Single. Note too that functional LINQ calls like Select and Where will return an IEnumerable<Item>, not another Maybe<Item> without implementing those methods specifically.
An Abstract Type, with Private Concrete Types
public abstract class Maybe<Item>
{
abstract Output Match<Output>(
Func<Item, Output> Some,
Func<Output> None);
public static implicit operator Maybe<Item>(Item value) => new Some(value);
public static Maybe<Item> Not() => new No<Item>();
private class Some<Item> : Maybe<Item>
{
private Item Value { get; }
public Some(Item value) => Value = value;
public override Output Match<Output>(
Func<Item, Output> Some,
Func<Output> None) => Some(Value);
}
private class No<Item> : Maybe<Item>
{
public override Output Match<Output>(
Func<Item, Output> Some,
Func<Output> None) => None();
}
}
This implementation hides the underlying mechanisms and exposes only a single API for working with a Maybe type: Match. The goal of Match is to obtain a new value by providing code to handle both when a value exists, and when a value does not. Consider the following equivalent code:
string message;
SomeType? oldSchool = null;
if (oldSchool is not null) message = $"Variable was not null: {oldSchool}";
else message = "Variable was null";
Maybe<SomeType> newSchool = Maybe<SomeType>.Not();
message = newSchoole.Match(
Some: instance => $"Variable was not null: {instance}",
None: () => "Variable was null");
This provides some helpful constraints to the programmer, as both cases must be handled or the code won't compile. However there's no way to use the presence of a value to do some work that has no return, at least not without borrowing other concepts from functional languages like a Unit value. More functional abstract methods are possible, like mapping and flattening (Select and SelectMany in LINQ lingo.)
An Abstract Type, with Public Concrete Types
This is the implementation I use. It allows us to use familiar C# pattern matching, and provides plenty of avenues for functional extension methods as needed.
public abstract class Maybe<T>
{
static Maybe() {
Some<T>.SetFactory();
}
internal static Func<T, Some<T>> newSome = null!;
public static implicit operator Maybe<T> (T? value) => value is null ? No<T>.Value : newSome(value);
}
public class Some<T> : Maybe<T>
{
public T Value { get; }
private Some(T value) => Value = value;
public static implicit operator T(Some<T> some) => some.Value;
internal static Unit SetFactory()
{
newSome = value => new Some<T>(value);
return Unit.Value;
}
}
public class No<T> : Maybe<T>
{
private No() { }
public static Maybe<T> Value => new No<T>();
}
This implementation allows the client code to observe and work with Some<Item> and No<Item> types, but hides their constructors leaving only the implicit conversion and No<Item>.Value as the way to obtain them as Maybes.
We can use C#'s built in pattern matching to provide an API that feels much more idiomatic, like the following code:
Maybe<User> maybeUser = GetUser(id);
if (maybeUser is Some<User> someUser)
{
DoThingWith(someUser.Value);
}
else
{
ReportMissingUser(id);
}
string message = maybeUser switch
{
Some<User> someUser => someUser.Value.Greeting(),
No<User> => "User Not Found",
_ => throw new BadMaybe(maybeUser); // Implementation not shown.
}
Conclusion
There are several ways to implement a maybe type in C#, each with their own pros and cons, but all of them are more expressive, and more type safe that relying on null checking.
Find this article and more on my substack
Top comments (0)