Annotating your C# code - Migrating to nullable reference types - Part 3
Edit on GitHubIn 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.
In this series:
- Nullable reference types in C#
- Internals of C# nullable reference types
- Annotating your C# code (this post)
- Techniques and tools to update your project
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();
}
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();
}
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.
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
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
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
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;
}
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();
}
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;
}
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();
}
}
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();
}
}
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 associatedbool
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);
}
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)
{
// ...
}
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-nullstring
elements.List<string?>
is a collection that may containnull
elements.List<string?>?
is a collection that may benull
, and may containnull
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 ...
}
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.
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.
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());
}
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.
2 responses