*in this article I will use list to refer to any enumerable collection not just List<T>
The O.G. Singleton
In computing it can often be beneficial to work with lists. They are null-safe by default as an empty list is always valid. From time to time we will expect that a list we are working with will contains one and only one value. The correct mathematical term for this is Singleton
, a term which may be confusing now given the rise in popularity of IoC containers and service lifetimes, but take this as another reminder that context always matters.
Out of the box, LINQ gives us two methods for accessing the single item in a singleton: .Single()
and .SingleOrDefault()
. This has made me very angry and should be widely regarded as a bad move.
Single()
IFF the list contains a single item (or a single item matching a given predicate) Single()
will return that item. Else, this method will throw an exception. Which is great for halting program execution, but not so great for error handling. We have methods of proving that a list contains only one item to us and other developers, but not great ways of proving it to the compiler.
if (items.Count() != 1) return;
// A little while later
return items.Single();
In the example above we know we've checked and that single will not throw, but the compiler is unaware of this assumption, and so, if the check ever gets removed, there's nothing letting the developer know that this assumption is not longer true. It's possible to solve this with a type
public class Singleton<T>
{
public T Item { get; }
private Singleton(T item) => Item = item;
public bool TryParse(
IEnumerable<T> list,
[NotNullWhen(true)]
Singleton<T>? singleton)
{
singleton = null;
if (list.Count() != 1) return false;
singleton = new(list.Single());
return true;
}
}
Yes, yes, I know I've used Single()
in the type I've said would let us stop using Single()
but the enforcement of this constraint is the raison d'etre of this type and so I think it's pretty clear to later developers that remove the check here is a breaking change. This is a perfectly workable solution for older .NET code, but we can (and will) do better later on.
SingleOrDefault()
A popular alternative, which does not throw is SingleOrDefault()
which will always return a value. If the list is a singleton, it will return the single item, if not it will return the default
(null for reference types). Combined with nullable
enabled, and treating warnings as errors, the compiler will force a null check for you, but that isn't always the case.
Enter C# 11
List patterns were introduced in C# 11, and they have come to the rescue here. We now have a compiler safe way to enforce either A) that a list is a singleton, or B) that the developer has handled all alternatives.
var singleItem = list switch
{
[] => HandleEmptyList(),
[var item] => item,
[var first, var second, ..] => HandleListWithMultipleItems(),
};
The compiler is now completely aware of the assumption we are making. Therefore if any of the cases are removed, it will give us an error. Well, a warning that you really should be considering an error.
We can adopt this to a variety of error handling methods, below are examples for Try
, error handling delegates, and Result
types.
public static bool TrySingle<T>(
this IEnumerable<T> list,
[NotNullWhen(true)]
out T? item)
{
item = list switch
{
[] => null,
[T singleValue] => singleValue,
[T first, T second, ..] => null
};
return item is not null;
}
public static T? Single(
this IEnumerable<T> list,
Action<string> error)
{
private T? fail(string errorMsg)
{
error(errorMsg);
return null;
}
return list switch
{
[] => fail("The list is empty"),
[T singleItem] => singleItem,
[T fist, T second, ..] => fail("The list contains multiple entries")
};
}
public static Result<T> ParseSingle(this IEnumerable<T> list)
=> list switch
{
[] => new Error("The list is empty"),
[T singleItem] => singleItem,
[T first, T second, ..] => new Error("The list contains multiple entries")
};
Note that while each of these methods don't present a significant value from the outside over something like SingleOrDefault()
, they still have a compiler checked assumption and are thus more resistant to being broken accidentally by later developers.
In your domain code, it's likely much more appropriate to write the switch
expression out in it's entirety as your domain likely has rules and processes for handling this failed assumption, and if it does not, go check with your customer to find out if it should.
Top comments (0)