When [NotNull] is null

  Subscribe
2/18/2020 - Marco von Ballmoos (updated on 9/18/2020)

I prefer to be very explicit about nullability of references, wherever possible. Happily, most modern languages support this feature non-nullable references natively (e.g. TypeScript, Swift, Rust, Kotlin).

As of version 8, C# also supports non-nullable references, but we haven't migrated to using that enforcement yet. Instead, we've used the JetBrains nullability annotations for years.1

Recently, I ended up with code that returned a null even though R# was convinced that the value could never be null.

The following code looks like it could never produce a null value, but somehow it does.

[NotNull] // The R# checker will verify that the method does not return null
public DynamicString GetCaption()
{
  var result = GetDynamic() ?? GetString() ?? new DynamicString();
}

[CanBeNull]
private DynamicString GetDynamic() { ... }

[CanBeNull]
private string GetString() { ... }

So, here we have a method GetCaption() whose result can never be null. It calls two methods that may return null, but then ensures that its own result can never be null by creating a new object if neither of those methods produces a string. The nullability checker in ReSharper is understandably happy with this.

At runtime, though, a call to GetCaption() was returning null. How can this be?

The Culprit: An Implicit Operator

There is a bit of code missing that explains everything. A DynamicString declares implicit operators that allow the compiler to convert objects of that type to and from a string.

public class DynamicString
{
  // ...Other stuff

  [CanBeNull]
  public static implicit operator string([CanBeNull] DynamicString dynamicString) => dynamicString?.Value;
}

A DynamicString contains zero or more key/value pairs mapping a language code (e.g. "en") to a value. If the object has no translations, then it is equivalent to null when converted to a string. Therefore, a null or empty DynamicString converts to null.

If we look at the original call, the compiler does the following:

  1. The call to GetDynamic() sets the type of the expression to DynamicString.
  2. The compiler can only apply the ?? operator if both sides are of the same type; otherwise, the code is in error.
  3. Since DynamicString can be coerced to string, the compiler decides on string for the type of the first coalesced expression.
  4. The next coalesce operator (??) triggers the same logic, coercing the right half (DynamicString) to the type it has in common with the left half (string, from before).
  5. Since the type of the expression must be string in the end, even if we fall back to the new DynamicString(), it is coerced to a string and thus, null.

Essentially, what the compiler builds is:

var result = 
  (string)GetDynamic() ?? 
  GetString() ?? 
  (string)new DynamicString();

The R# nullability checker sees only that the final argument in the expression is a new expression and determines that the [NotNull] constraint has been satisfied. The compiler, on the other hand, executes the final cast to string, converting the empty DynamicString to null.

The Fix: Avoid Implicit DynamicString-to-string Conversion

To fix this issue, I avoided the ?? coalescing operator. Instead, I rewrote the code to return DynamicString wherever possible and to implicitly convert from string to DynamicString, where necessary (instead of in the other direction).

public DynamicString GetCaption()
{
  var d = GetDynamic();
  if (d != null)
  {
    return d;
  }

  var s = GetString();
  if (s != null)
  {
    return s; // Implicit conversion to DynamicString
  }

  return GetDefault();
}

Conclusion

The takeaway? Use features like implicit operators sparingly and only where absolutely necessary. A good rule of thumb is to define such operators only for structs which are values and can never be null.

I think the convenience of being able to use a DynamicString as a string outweighs the drawbacks in this case, but YMMV.



  1. Java also has @NonNull and @Nullable annotations, although it's unclear which standard you're supposed to use.

Sign up for our Newsletter