The question of null
dotnetdesignThe past #
Null reference exceptions seems to be one of the most common runtime exceptions in .NET. Protecting against null requires discipline, and a lot of extra code to read through. For .NET Core 3.0, C# 8 the options shifted. At that time a project could be enabled to flag potential nullables not being handled. The project could also be set to turn these warnings into errors.
<Nullable>enable</Nullable>
<WarningsAsErrors>nullable</WarningsAsErrors>
Along with these setting two new operators were introduced to work with nulls. First, the
null-forgiving operator !
allows you to instruct the compiler that the object is not null, and so
allows access to any properties: myStringValue!.Length
. This operator should not be used
haphazardly.
The second operator is the null-conditional operator ?.
. It allows you to access object properties
more succinctly and protect yourself for accessing null by falling through to the end of the
expression if any part of it is null: myStringValue?.Length ?? 0
.
How to handle null #
Considering this new option I see four general approaches to handling null, all of which have their trade-offs.
- Nullables
- Try pattern
- Null value pattern
- Option pattern
In the examples below the following type will be used to return for the example methods:
public record TheReturnValue(string TheValue);
Nullables #
We define a method which potentially returns a nullable type:
internal class NullableExample
{
public TheReturnValue? DoSomething(bool useNullPath = false)
{
return useNullPath
? null
: new TheReturnValue(nameof(NullableExample));
}
}
Calling and handling the potential null value:
var nullableExample = new NullableExample();
Console.WriteLine(nullableExample.DoSomething()?.TheValue ?? "Empty");
Console.WriteLine(nullableExample.DoSomething(useNullPath: true)?.TheValue ?? "Empty");
The compiler helps us, to ensure the potential null is not being accessed inappropriately.
Try pattern #
The try pattern is not new. But the NutNullWhen
attribute was also only introduced at the same
time as the new null handling operators. Here we define a Try
method which potentially returns
null
:
internal class TryExample
{
public bool TryDoSomething([NotNullWhen(true)] out TheReturnValue? theReturnValue, bool useNullPath = false)
{
if (useNullPath)
{
theReturnValue = null;
return false;
}
else
{
theReturnValue = new TheReturnValue(nameof(TryExample));
return true;
}
}
}
Calling and handling the potential null value:
if (new TryExample().TryDoSomething(out var theTryGetReturnValue))
{
Console.WriteLine(theTryGetReturnValue.TheValue);
}
if (!new TryExample().TryDoSomething(out var theTryGetReturnValueNullScenario, useNullPath: true))
{
Console.WriteLine("Empty");
}
The approach is a little more awkward. It also does not work with async
methods.
The null value pattern #
There isn't anything new about the null value pattern. For this pattern we need to also define a new type which represents the case when the method does not have a value to return:
internal class NullValueExample
{
public TheReturnValue DoSomething(bool useNullPath = false)
{
return useNullPath
? new NullValueTheReturnValue()
: new TheReturnValue(nameof(NullValueExample));
}
}
internal record NullValueTheReturnValue() : TheReturnValue("Empty");
Calling and handling the potential null value:
var nullValueExample = new NullValueExample();
Console.WriteLine(nullValueExample.DoSomething().TheValue);
Console.WriteLine(nullValueExample.DoSomething(useNullPath: true).TheValue);
Pretty simple and straightforward. The awkwardness comes when you need to know if the object you have is the null value object.
The option pattern #
This pattern comes from functional languages. In functional languages it is an elegant solution to
working with null
. The biggest issue is it's unfamiliarly with developers who work in .NET. It
requires discipline to use.
There are many implementations which can be used. This is a simple implementation which allows an illustration of usage:
internal class Option<T> where T : class
{
private T? theObject = null;
public static Option<T> Some(T theObjectPassedIn) => new() { theObject = theObjectPassedIn };
public static Option<T> None() => new();
public Option<TResult> Map<TResult>(Func<T, TResult> map) where TResult : class =>
theObject is null ? Option<TResult>.None() : Option<TResult>.Some(map(theObject));
public T Reduce(T defaultValue) => theObject ?? defaultValue;
}
internal class OptionExample
{
public Option<TheReturnValue> DoSomething(bool useNullPath = false)
{
return useNullPath
? Option<TheReturnValue>.None()
: Option<TheReturnValue>.Some(new(nameof(OptionExample)));
}
}
Calling and handling the potential null value:
var optionExample = new OptionExample();
Console.WriteLine(optionExample.DoSomething().Map(theReturnValue => theReturnValue.TheValue).Reduce("Empty"));
Console.WriteLine(optionExample.DoSomething(useNullPath: true).Map(theReturnValue => theReturnValue.TheValue).Reduce("Empty"));
Conclusion #
For most use cases I have found using nullable types with the new nullable settings and operators
makes the most since for where I work. It's still a leaky abstraction, but it is familiar to
.NET developers and mostly protects against null
simply and sufficiently.