August 25, 2025
One of the most unfortunate parts of the nullability narrative in C# is the reuse of the T? syntax to denote two completely separate concepts for value types and reference types. This leads to some odd and confusing behaviour.
As you may know, nullable value types is a much older concept than nullable reference types. Nullable value types were introduced in C# 2.0, whereas nullable reference types came in C# 8.0. And they’re not the same. Nullable isn’t nullable.
For value types, T? is syntactic sugar for the wrapper type Nullable<T>. An expression like int? maybe = 5 compiles to int? maybe = new Nullable(5), wrapping the integer value in a nullable value. This means that T? and T are distinct types.
Nullable reference types are a very different beast. For reference types, T? is a communication device. It says something about intensions. In essence it says “I expect nulls here”. Its counterpart T communicates the opposite: “there shouldn’t be nulls here”. But once the compiler has done its job of warning that you may be violating your own intensions, there is no difference. T? and T are the same type, and that type allows nulls.
“So what?” you may ask. How is this a problem? I’m glad you asked! Let’s take a look at an example to illustrate the consequences of overloading ? to mean different things for value types and reference types.
The Enumerable class contains many extension methods for types that implement the IEnumerable<T> interface. However, it does not contain a method that corresponds to List.choose in F#! Let’s try to fix that.
List.choose is interesting in that it combines the effect of map and filter, or Select and Where in C#. It maps each element of a list to an optional value of some type, and then it filters based on that mapping, keeping only the genuine values as it were. In case that’s not entirely clear, my first naive attempt at writing such a method in C# should make it clearer.
public static IEnumerable<TR> SelectNotNull<T, TR>(
this IEnumerable<T> source,
Func<T, TR?> fn)
{
return source.Select(fn)
.Where(it => it != null)
.Cast<TR>();
}
This compiles, but unfortunately it doesn’t quite work.
The intention here is to call the fn function on each element, and then to filter out any null values. You’ll note that fn returns a TR? value, indicating a nullable value (for value types), or at least something that could be null (for reference types), whereas the return type of SelectNotNull is IEnumerable<TR>. No allowance for null there. Indeed, that’s the whole point of SelectNotNull!
What is the problem? After all, it compiles. Well, what happens when we try to use it?
Let’s start with reference types. We write the following code to test our new method.
IEnumerable<string?> maybeStrs = ["foo", null, "baz", null, "quux"];
IEnumerable<string> strs = maybeStrs.SelectNotNull(it => it);
This works as intended! It filters out the nulls and gives us just the actual strings - as well as the type to go with it. Great stuff!
Now let’s try value types. We write the equivalent code:
IEnumerable<int?> maybeNums = [1, null, 3, null, 7];
IEnumerable<int> nums = maybeNums.SelectNotNull(it => it);
It doesn’t compile! We get the compiler error CS0266, with the explanation "cannot implicitly convert type IEnumerable<int?> to IEnumerable<int>. But how? Why? How can this be? First, it worked for strings, and second, the whole point of the method is that the return type sheds the possibility of null! So what implicit conversion could we possibly be talking about?
Well. The problem is, of course, in the interpretation of the question mark. As I mentioned, the compiler can interpret T? in two very different ways: either as sugar for Nullable<T> or as T’y with a chance of null. What it can’t do is interpret it as both at the same time. It has to choose. And it sides with the reference type interpretation, apparently. Which means that for value types, there is no cast. It does filter, but that’s just half the job. I told it to cast. There’s a cast there.
So. How can we fix this? Is it fixable? It is! Duplication to the rescue!
public static IEnumerable<TR> SelectNotNull<T, TR>(
this IEnumerable<T> source,
Func<T, TR?> fn)
where TR : class
{
return source.Select(fn)
.Where(it => it != null)
.Cast<TR>();
}
public static IEnumerable<TR> SelectNotNull<T, TR>(
this IEnumerable<T> source,
Func<T, TR?> fn)
where TR : struct
{
return source.Select(fn)
.Where(it => it != null)
.Cast<TR>();
}
So, the only thing I’ve done here is to create two copies of the exact same code, and add a different type constraint to each copy. Luckily, since type constraints are part of the signature of the method and there is no ambiguity, I am allowed to make this overload. Not only that, but it solves our problem! Now it works for both strings and ints!
If you think this is nuts, you’re right. It is. But it is also completely understandable. Now the compiler doesn’t have to choose between reference type interpretation and value type interpretation of T?. It can do both, since we’ve given it two methods to work with. The type constraint makes the choice of interpretation unambiguous.
But it’s still nuts.