DEV Community 👩‍💻👨‍💻

Maarten Balliauw
Maarten Balliauw

Posted on • Originally published at blog.maartenballiauw.be on

Annotating your C# code - Migrating to nullable reference types - Part 3

In the previous post, we looked at some internals of C# nullable reference types, and the nullable annotation context.

Today, let’s look at the many options for annotating your code and various ways to help the flow analysis understand your code. As a result, you (and anyone consuming your libraries) will get better and more reliable hints from the IDE and the C# compiler.

The need for more fine-grained annotations

So far, we’ve only been annotating our code with ? to inform flow analysis that a reference type can be null when nullable annotations are enabled. This one annotation may not be enough for all scenarios…

Let’s start with a piece of code that takes a title (like the one from this blog post) and makes a “slug” out of it, a representation of the title that can be used in URLs. It’s a very, very naive implementation, so don’t go using it in production. But it’s sufficient as an example. Also note the #nullable disable directive - the nullable annotation context is disabled in this example.

#nullable disable

public static string Slugify(string value)
{
    if (value == null)
    {
        return null;
    }

    return value.Replace(" ", "-").ToLowerInvariant();
}

Enter fullscreen mode Exit fullscreen mode

The Slugify method can return a string (or null), and accepts a string parameter that can also be a string instance, or null. Let’s convert this method to use nullable reference types.

#nullable enable

public static string? Slugify(string? value)
{
    if (value == null)
    {
        return null;
    }

    return value.Replace(" ", "-").ToLowerInvariant();
}

Enter fullscreen mode Exit fullscreen mode

So far, so good. The logic remained the same, and support for null on both the input and output of this method remains.

Now let’s use this method, pass it a known non-null string, and look at it in the IDE (or compile it).

var slug = Slugify("This is a test");

Console.WriteLine(slug.Length); // Warning - CS8602 Dereference of a possibly null reference.

Enter fullscreen mode Exit fullscreen mode

We know that when we give the Slugify() method a non-null value, a non-null string is returned. However, the compiler’s flow analysis does not know this and flags dereferencing .Length as a warning. It uses the information available from the nullable context, which says both in- and output can be null.

Luckily, there are several attributes available that can help us in cases where the compiler can’t figure things out. In this case, we can use the NotNullIfNotNullAttribute to give the compiler a hint. We can tell it the return value is not null, if a specific named argument is also not null:

[return: NotNullIfNotNull("value")]
public static string? Slugify(string? value)
{
    // ...
}

var slug = Slugify("This is a test");

Console.WriteLine(slug.Length); // All good! Known to be not null

Enter fullscreen mode Exit fullscreen mode

Let’s look at another example. After doing a null check, the C# compiler knows that a specific reference can no longer be null. A quick refresher:

var s = DateTime.Now.Day == 1 ? "" : null;

if (s == null) return;

Console.WriteLine(s.Length); // no warning here, s is known to be not null

Enter fullscreen mode Exit fullscreen mode

The C# compiler only knows that a null check was performed if you write a null check. Yet, it does seem to know string.IsNullOrEmpty() is also doing such a check.

var s = DateTime.Now.Day == 1 ? "" : null;

if (string.IsNullOrEmpty(s)) return;

Console.WriteLine(s.Length); // no warning here, s is known to be not null

Enter fullscreen mode Exit fullscreen mode

How does this work? The string.IsNullOrEmpty() method is annotated with the NotNullWhenAttribute.

public static bool IsNullOrEmpty([NotNullWhen(false)] string? value)
{
    return (value == null || 0 == value.Length) ? true : false;
}

Enter fullscreen mode Exit fullscreen mode

The NotNullWhenAttribute lets the C# compiler know that the reference passed in the value parameter is not null when this method returns false.

There are quite a few of these attributes, with good descriptions and examples in the docs.

Preconditions

Preconditions are used when writing to a parameter, field, or property setter.

In the following example, the Username property getter can not return null, while the property setter does allow null. The AllowNull attribute lets you specify a parameter, field, or property setter accepts null:

private string _username = Guid.NewGuid().ToString();

[AllowNull]
public string Username
{
    get => _username;
    set => _username = value ?? Guid.NewGuid().ToString();
}

Enter fullscreen mode Exit fullscreen mode

Alternatively, DisallowNull lets you specify that a parameter, field, or property setter won’t accept null, even if it’s declared as nullable.

Post-conditions

Post-conditions are used when reading from a field, property, or return value.

In the following example, the MiddleName property getter may return null, while the property setter requires a non-null value. The MaybeNull attribute lets you specify a field, property, or return value may be null:

private string? _middleName = default;

[MaybeNull]
public string MiddleName
{
    get => _middleName;
    set => _middleName = value;
}

Enter fullscreen mode Exit fullscreen mode

Alternatively, NotNull lets you specify that a field, property, or return value won’t return null, even if it’s declared as nullable.

The MaybeNull and NotNull attributes are also useful in combination with generics, as we’ll see later in this post.

Conditional post-conditions

Conditional post-conditions can be used to make method arguments dependent on a method return value.

We’ve already seen NotNullWhen and NotNullIfNotNull earlier, and there is also MaybeNullWhen.

  • MaybeNullWhen - A non-nullable argument may be null when the method returns the specified bool value.
  • NotNullWhen - A nullable argument won’t be null when the method returns the specified bool value.
  • NotNullIfNotNull - A return value isn’t null if the argument for the specified parameter isn’t null.

Helper methods

With helper methods, you can specify whether a certain field is initialized after execution.

In this Person class, the _id field initially is null, even though it’s defined as being non-nullable. The EnsureIdentifier() method is called in the constructor, and makes sure _id does get a value.

public class Person
{
    private string _id;

    public Person() => EnsureIdentifier();

    private void EnsureIdentifier()
    {
        _id = Guid.NewGuid().ToString();
    }
}

Enter fullscreen mode Exit fullscreen mode

However, when compiling, you’ll see a warning emitted:CS8618 Non-nullable field ‘_id’ must contain a non-null value when exiting constructor. Consider declaring the field as nullable.

This can be resolved by adding the MemberNotNull annotation on EnsureIdentifier(), telling the compiler that this method sets the _id field when it returns.

public class Person
{
    private string _id;

    public Person() => EnsureIdentifier();

    [MemberNotNull(nameof(_id))]
    private void EnsureIdentifier()
    {
        _id = Guid.NewGuid().ToString();
    }
}

Enter fullscreen mode Exit fullscreen mode

MemberNotNull lets you specify a member won’t be null when the method returns. There’s also MemberNotNullWhen, which lets you specify the listed member won’t be null when the method returns the specified bool value.

Failure conditions

Lastly, there are two attributes that you can use to specify when no result will be returned, and further analysis is not needed:

  • DoesNotReturn - The method or property always throws an exception, and thus never returns.
  • DoesNotReturnIf - If the associated bool parameter has the specified value, the method or property always throws an exception, and thus never returns.

For example, this WriteName() method calls ThrowArgumentNullException() when name is null. Since that method is annotated with DoesNotReturn, the compiler’s flow analysis now knows that the Console.WriteLine below will only run when name is not null.

[DoesNotReturn]
private void ThrowArgumentNullException(string argumentName)
    => throw new ArgumentNullException(argumentName);

public void WriteName(string? name)
{
    if (name == null)
    {
        ThrowArgumentNullException(nameof(name));

        // any code here will never execute
    }

    Console.WriteLine(name.Length);
}

Enter fullscreen mode Exit fullscreen mode

In summary, by annotating your methods with these attributes, the C# compiler can provide you (and anyone who consumes your code) with more accurate warnings. And again, the docs contain good examples on how to use these attributes.

JetBrains Annotations

Introduced many years ago, JetBrains has many annotation attributes that can be used to provide hints to the IDE, even before C# 8 was a thing. If you’re using ReSharper or JetBrains Rider, you can sprinkle these attributes through your code base and get better navigation, type hints, language injections, string formatting, and more.

JetBrains Annotations have been around for years, and many existing code bases have them in use. For example, Hangfire and Unity are shipping their code with JetBrains Annotations, helping their users with information about nullability even before the compiler supported it.

Here’s a summary of the JetBrains Annotations attributes related to nullability:

Attribute What does it mean?
CanBeNull Indicates that the value of the marked element could be null sometimes, so checking for null is required before its usage.
NotNull Indicates that the value of the marked element can never be null.
ItemNotNull Can be applied to symbols of types derived from IEnumerable as well as to symbols of Task and Lazy classes to indicate that the value of a collection item, of the Task.Result property or of the Lazy.Value property can never be null.
ItemCanBeNull Can be applied to symbols of types derived from IEnumerable as well as to symbols of Task and Lazy classes to indicate that the value of a collection item, of the Task.Result property or of the Lazy.Value property can be null.
ContractAnnotation Let you define expected outputs for given inputs. For example, you can specify that when null is passed as an argument, a method returns null, and more.

The ContractAnnotation is really powerful: it provides a powerful syntax to describe the input and output conditions of a method.

A simple example would be this TryDeserialize method, where a ContractAnnotation can be added to convey that when the json parameter is null, the method returns false and person will be null.

[ContractAnnotation("json:null => false,person:null")]
public bool TryDeserialize(string json, out Person? person)
{
    // ...
}

Enter fullscreen mode Exit fullscreen mode

I find the ContractAnnotation more clear than C#’s own attributes, as they follow the natural flow: if the input is null, the output is false. C#’s own attributes are a bit backward. For example NotNullWhen tells the compiler that when the output is false, the input must have been null.

Now you may ask, why am I mentioning these annotations? Especially with native annotations in C#8 and beyond? There are a number of reasons.

First, teams that have been using these tools in the past will have an easier migration path towards using C#’s nullable annotations. The JetBrains tools have several automated quick fixes to handle this migration for you, as we’ll see in the next part of this series.

Second, you may be consuming a library that ships JetBrains Annotations with it. If you’re using ReSharper or Rider, the IDE will use those annotations to help you determine whether reference types can be null (or not), even if you’re using the C# annotations.

I won’t say they were first, but… JetBrains were first in paving the road towards better expressing nullability in C# code bases. You can clearly see the similarities between JetBrains flow based analysis and the solution for nullability the C# compiler settled on.

Having annotated libraries out there is of great value when migrating your own code base. Do keep in mind that the C# compiler doesn’t recognize the JetBrains Annotations, but you can use them with Rider and ReSharper to get improved nullability analysis on top of any C# 8 annotations.

Tip: The JetBrains Annotations have several other utilities. For example, you can use them to treat string parameters as ASP.NET MVC controllers/actions, and many more.

Expressing nullability with generics

The nullable annotations can also be applied to generics. Here are a few examples:

  • List<string> is a collection that contains non-null string elements.
  • List<string?> is a collection that may contain null elements.
  • List<string?>? is a collection that may be null, and may contain null elements.

But what with List<T>… Is T nullable or not? Is it a value type or a reference type?

You can use the MaybeNull attribute to tell the compiler about whether a generic method may return null:

[return: MaybeNull]
public T Find<T>(IEnumerable<T> sequence, Func<T, bool> predicate)
{
    // ... find by predicate, or return null ...
}

Enter fullscreen mode Exit fullscreen mode

You can also use generic type constraints to inform the compiler about the capabilities a type argument must have. With C# nullable reference types, there’s a new generic constraint to prevent a generic parameter from being nullable: notnull.

Thanks to the where T : notnull in the following example, WriteToConsole() can only be called with a non-null T item:

#nullable enable

public static void WriteToConsole<T>(T item)
    where T : notnull
{
    Console.WriteLine(item);
}

string? s = null;
WriteToConsole(s); // CS8714 Nullability of type argument 'string?' doesn't match 'notnull' constraint.

Enter fullscreen mode Exit fullscreen mode

The notnull constraint limits the type parameter to be either a value type or a non-nullable reference type. Use the class constraint to ensure a non-nullable reference type, or the class? constraint to allow a nullable reference type.

Nullable reference types in referenced code, libraries and frameworks

Migrating to using nullable reference types becomes easier when your dependencies have nullable annotations in place, whether the native C# attributes or using JetBrains Annotations. This is true for third-party dependencies, including the .NET framework, but it’s also true for first-party dependencies developed by your team or company.

With .NET 6, most if not all of the framework code has been annotated, giving you design-time and compile-time help when interacting with framework code. Having annotations in the framework helps in annotating your own code base. In the following example, Directory.GetParent() may return null, which means your own function will need to also return a nullable string, do a null check, throw an Exception, or work around this possibly null reference in another way.

Annotated code in the framework

Once again, it’s important to realize nullable reference types and annotations are not enforced at runtime.

Nullable reference types are a C#-only feature. The .NET Common Language Runtime (CLR) does not distinguish between string and string?, you don’t get runtime safety when it comes to nullability!

Consumers of your code may also use an older C# language compiler or IDE, or have disabled nullable reference types and happily invoke your code that expects a non-nullable parameter with null.

Because of all of this, you still need to do null checks, even with nullable reference types enabled.

#nullable enable

public string Reverse(string original)
{
    if (original == null)
    {
        throw new ArgumentNullException(nameof(original));
    }

    // or:
    // ArgumentNullException.ThrowIfNull(original);

    return new string(original.Reverse().ToArray());
}

Enter fullscreen mode Exit fullscreen mode

Nothing stops you from calling Reverse(null). To guard against null at runtime, you’ll need to check for null and throw ArgumentNullException (or circumvent the null value in another way).

In summary, even with nullable reference types enabled, you still need to do null checks on untrusted and/or external input.

Do you need to redesign your API?

An additional benefit to enabling nullable reference types, is that it may expose API design flaws.

When you have to annotate an API as being nullable because there is one case where it can return null, maybe you will want to re-think that design. Do you want to keep allowing null? Do you want to instead make the API non-nullable, and handle that null case in another way?

Much like with async/await, nullable annotations will need to be present in your entire project to get the most value out of them. But also like with async/await, you may want to set boundaries on where certain values can be null or non-null, and redesign your API accordingly.

Conclusion

In this post, we have seen the many options for annotating your code for nullability. Using these annotations helps the compiler’s flow analysis in understanding your code. Thanks to annotated code, you and folks consuming your code get better and more reliable hints from the IDE and the C# compiler.

In the next and final post in this series, we’ll look at some techniques and tools that are available to migrate an existing code base to using nullable reference types.

Top comments (0)

🌚 Life is too short to browse without dark mode