Puzzler with structs, generics and nullability

Recently I found a non-obvious behavior of combining structs with nullability.

All code for this post is available at my GitHub

The puzzler

Let’s say we want to write a generic transform function for nullable:

  public static TResult? Transform<TSource, TResult>(
        TSource? source,
        Func<TSource, TResult> transformation)
        => source is null
            ? default
            : transformation(source);

It gets a nullable argument, a transformation (function that gets an argument and returns a result) and applies transformation if argument is not null. Otherwise, it returns null.

It works great until you try to apply this to some value type, say DateTime:

DateTime? source = null;
DateTime? result = NullableStructsTransformer.Transform(source, x => x.AddDays(1));

You are getting the following:

Severity Code Description Project File Line Suppression State Error CS1061 ‘DateTime?’ does not contain a definition for ‘AddDays’ and no accessible extension method ‘AddDays’ accepting a first argument of type ‘DateTime?’ could be found

Ok you are are adding an overload with struct constrain and it compiles:

public static TResult? Transform<TSource, TResult>(
        TSource? source,
        Func<TSource, TResult> transformation)
        where TSource : struct // <-- This one
        => source is null
        ? default
        : transformation(source.Value);

But… it does not work. For non-null values it return transformed values, but for nulls it returns default values. For example, for dates it returns <0001-01-01 00:00:00.000>.

The following test fails:

 [Fact]
public void Transform_IfNullStruct_ReturnsNull()
{
    DateTime? source = null;
    DateTime? result = NullableStructsTransformer.Transform(source, x => x.AddDays(1));
    result.Should().BeNull();
}

Did not expect result to have a value, but found <0001-01-01 00:00:00.000>.

The fix

The fixed version is below

    public static TResult? TransformFixed<TSource, TResult>(
        TSource? source,
        Func<TSource, TResult> transformation)
        where TSource : struct
        where TResult : struct // <-- Add constraint on result
        => source is null
            ? default(TResult?) // <-- Specify  return type
            : transformation(source.Value); 

The explanation

Lets insert our code into sharplab to compare:

Not working version:


    [return: System.Runtime.CompilerServices.Nullable(2)]
    public static TResult Transform<TSource, [System.Runtime.CompilerServices.Nullable(2)] TResult>(Nullable<TSource> source, [System.Runtime.CompilerServices.Nullable(new byte[] { 1, 0, 1 })] Func<TSource, TResult> transformation) where TSource : struct
    {
        return (!source.HasValue) ? default(TResult) : transformation(source.Value);
    }

Working version:

    public static Nullable<TResult> TransformFixed<TSource, TResult>(Nullable<TSource> source, [System.Runtime.CompilerServices.Nullable(new byte[] { 1, 0, 0 })] Func<TSource, TResult> transformation) where TSource : struct where TResult : struct
    {
        return (!source.HasValue) ? null : new Nullable<TResult>(transformation(source.Value));
    }

.NET have two nullable types implementations:

  • reference nullable type
  • value nullable types

The first kind is not a true .net runtime type, but just an attribute + compiler support.

The second kind is a struct wrapper around value.

When compiler does not know whether result will be value type or not it generates a generic method which returns a result, but marked with nullable attribute. Which works with reference types (since they are always nullable from runtime standpoint), but produces a strange hybrid in case of value types: struct marked with a nullable attribute instead of Nullable<T> for struct.

So actually, instead of default(TResult?) in case of struct it produces default(TResult) which is empty value.

Further steps

We have two versions: 1st one to transform structs to reference types; 2nd one to transform structs to structs.

If somebody accidentally uses 1st version instead of second, we can have compiled code with a problem which will be found in runtime.

Fortunately, we can prevent it easily: just add

where TResult: class

into type constraints of the first version.

All code for this post is available at my GitHub

Written on June 10, 2023