Nullable.Extensions
A set of C# extension methods to help working with nullable types by implementing the Maybe monad on top of `T?`.
Install / Use
/learn @bert2/Nullable.ExtensionsREADME
Nullable.Extensions
Nullable.Extensions is a set of C# extension methods to help working with nullable types by implementing the Maybe monad on top of T?. This includes nullable value types (NVTs) and nullable reference types (NRTs).
Note
I consider this library experimental by now. Due to C#'s somewhat inconsistent implementation of NRTs, using a dedicated maybe-like type will result in more user-friendly and safer code. (Read more)
Table of contents
Prerequisites
- your project's
TargetFrameworkmust benetcoreapp3.1ornetstandard2.1 - enabled nullable reference types (via
<Nullable>enable</Nullable>in your.csprojfile or#nullable enablein your.csfile) - import the namespaces:
// required:
using Nullable.Extensions; // extension methods on `T?`
// optional:
using Nullable.Extensions.Async; // async extension methods on `Task<T?>`
using Nullable.Extensions.Linq; // enables LINQ's query syntax on `T?`
// utility:
using Nullable.Extensions.Util; // Nullable variants of framework functions. This is the way.
using static Nullable.Extensions.Util.TryParseFunctions; // helper functions to try-parse built-in types as `T?`
You might encounter problems when you are using the extension methods from the namespaces Async or Linq. See the sections below on how to resolve those.
The namespace Nullable.Extensions.Util is meant for functions that re-implement common framework behavior the "nullable way". For instance, instead of retrieving a value from a dictionary safely via bool TryGetValue(K, out V), its nicer to use V? TryGetValue(K). You feel there is one missing? Got an idea for a useful utility function? Please, let me know by creating an issue or a pull request!
Usage examples
All examples assume the namespace Nullable.Extensions has been imported.
Filtering and parsing nullable user input:
int num = GetUserInput() // returns `string?`
.Filter(s => s.All(char.IsDigit))
.Filter(s => s.Length < 10)
.Map(int.Parse)
?? throw new Exception("No input given or input was not numeric");
With the nullable variant of int.TryParse() this can be simplified:
using static Nullable.Extensions.Util.TryParseFunctions;
// ...
int num = GetUserInput("abc")
.Bind(TryParseInt)
?? throw new Exception("No input given or input was not numeric");
Using Switch() to provide a mapping and a default value in one go:
int num = GetUserInput() // returns `string?`
.Bind(TryParseInt)
.Switch(notNull: n => n - 1, isNull: () => 0);
However, I'd prefer using an explicit Map() and the null-coalescing operator ??:
int num = GetUserInput()
.Bind(TryParseInt)
.Map(n => n - 1)
?? 0;
With Switch() and the ?? operator null-value handling can only be done at the end of a chain. Using Else(), however, we can also handle nulls in the middle of a chain and replace them with alternative values:
int num = GetUserInput(prompt: "Enter a number:")
.Bind(TryParseInt)
.Else(() => GetUserInput(prompt: "A number PLEASE!").Bind(TryParseInt)) // One more chance...
.Else(() => TryGetRandomNumber()) // If you don't care then I don't care.
.Map(n => n - 1)
?? 0;
Working with Task<T?>
The namespace Nullable.Extensions.Async contains asynchronous variants of most of the extension methods. Importing them enables the fluent API on Task<T?>.
using Nullable.Extensions;
using Nullable.Extensions.Async;
using Nullable.Extensions.Util;
using static Nullable.Extensions.Util.TryParseFunctions;
// ...
public async Task<User?> LoadUser(int id) => // ...
string userName = await requestParams // a `Dictionary<string, string>`
.TryGetValue("userId")
.Bind(TryParseInt)
.BindAsync(LoadUser)
.Map(u => u.Name)
?? "n/a";
Note that you only have to await the result once at the very top of the method chain.
Known issues
Fixing "The call is ambiguous between..."
When you are using extension methods from Nullable.Extensions.Async, you might occasionally encounter an error due to the compiler being unable to determine the correct overload:
string? msg = await Task
.FromResult<string?>("world")
.Map(s => $"Hello, {s}!"); // ERROR CS0121
CS0121: The call is ambiguous between the following methods or properties: 'Map<T1, T2>(T1?, Func<T1, T2>)' and 'Map<T1, T2>(Task<T1?>, Func<T1, T2>)'
Fortunately, this is easy to fix by assisting the type inference with an explicit type declaration on the lambda parameter:
string? msg = await Task
.FromResult<string?>("world")
.Map((string s) => $"Hello, {s}!"); // no error
The fix is only needed under certain circumstances. Most of the time this fix should not be needed and you can let the compiler infer the type.
Fixing conflicts with LINQ
When you are using extension methods from Nullable.Extensions.Linq, you might occasionally encounter situations where the compiler picks the wrong overload for Select(), Where(), or SelectMany():
var xs = new[] { 1, 2, 3 }.Select(x => x.ToString()); // WARNING CS8634
In the above example it was probably intended to use System.Linq.Enumerable.Select(), but the compiler chose the Select() extension on T? instead. A compiler warning might indicate this issue:
CS8634: The type 'string?' cannot be used as type parameter 'T2' in the generic type or method 'SelectExt1.Select<T1, T2>(T1?, Func<T1, T2>)'. Nullability of type argument 'string?' doesn't match 'class' constraint.
Again, this is also easy to fix by assisting the type inference with an explicit type declaration on the lambda parameter:
var xs = new[] { 1, 2, 3 }.Select((int x) => x.ToString()); // no warning and correct `Select()`
Method reference
Core functionality
T2? T1?.Bind<T1, T2>(Func<T1, T2?> binder)
Evaluates whether the T1? has a value. If so, it is provided to the binder function and its result is returned. Otherwise Bind() will return null.
int? ParseInt(string s) => int.TryParse(s, out var i) ? (int?)i : null;
int? num = Nullable("123").Bind(ParseInt);
Similar to Map() except that binder must return a nullable type.
T? T?.Filter<T>(Func<T, bool> predicate)
Turns T?s that don't satisfy the predicate function into nulls. If T? already was null it will just be forwarded as is.
int? even = Nullable(13).Filter(n => n % 2 == 0);
// `even` will be `null`
T2? T1?.Map<T1, T2>(Func<T1, T2> mapping)
Evaluates whether the T1? has a value. If so, it is provided to the mapping function and its result is returned. Otherwise Map() will return null.
int? succ = Nullable(13).Map(n => n + 1);
Similar to Bind() except that mapping must return a non-nullable type.
Support
T? T.AsNullable<T>()
Converts a T into a T?.
string str1 = "hello";
string? str2 = str1.AsNullable();
Implemented for completeness. Most of the time the implicit conversions from T to T? will be sufficient.
T? T?.Else<T>(Func<T?> onNull)
Evaluates whether the T? has a value. If so, it is simply forwarded untouched. When it's null the onNull function will be evaluated to calculate a replacement value. The result of onNull() might also be null.
string? yourMessage = null;
string? greeting = yourMessage.Else(() => "Hello world!");
int? one = Nullable(1).Else(() => 13);
Else() is useful when implementing simple error handling. Its advantage over Switch() and ?? being that it can be used in the middle a method chain rather than only at the end of one.
T? Nullable<T>(T x)
Creates a nullable type from a value.
using static Nullable.Extensions.NullableClass;
using static Nullable.Extensions.NullableStruct;
// ...
string? s = Nullable("hi");
int? i = Nullable(13);
`
