Help the compiler, and the compiler will help you. Subtleties of working with nullable reference types in C#
Tags: dotnet, csharp, tutorial, programming
Published: 2023-01-24 07:35:37

Nullable reference types appeared in C\# 3 years ago\. By this time, they found their audience\. But even those who work with this "beast" may not know all its capabilities\. Let's figure out how to work with these types more efficiently\.


![Image description](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/vprtu0qyttn3f984nug3.png)

## Introduction

Nullable reference types are designed to help create a better and safer application architecture\. At the code writing stage, it is necessary to understand whether this or that reference variable can be *null* or not, whether the method can return *null*, and so on\.

It is safe to say that every developer has encountered NRE \(*NullReferenceException*\)\. And the fact that this exception can be generated at the development stage is a good scenario, because you can fix the problem immediately\. It is much worse when the user finds the problem when working with the product\. Nullable reference types help protect against NRE\.

In this article I will talk about a number of non\-obvious features related to nullable reference types\. But it's worth starting with a brief description of these types\.

## A word about nullable reference

In terms of program execution logic, a nullable reference type is no different from a reference type\. The difference between them is only in the specific annotation that the first one has\. The annotation allows the compiler to conclude whether a particular variable or expression can be *null*\. To use nullable reference types, you need to make sure that the nullable context is enabled for the project or file \(I will describe later how to do this\)\.

To declare a nullable reference variable, add '?' at the end of the type name\.

Example:

```cpp
string? str = null;
```

Now the variable *str* can be *null*, and the compiler will not issue a warning for this code\. If you don't add '?' when declaring a variable and assigning it with *null*, a warning will be issued\.

It is possible to suppress compiler warnings about possible writing *null* to a reference variable that is not marked as nullable\.

Example:

```cpp
object? GetPotentialNull(bool flag)
{
  return flag ? null : new object();
}

void Foo()
{
  object obj = GetPotentialNull(false);
}
```

The *obj* variable will never be assigned with *null*, but the compiler does not always understand this\. You can suppress the warning as follows:

```cpp
object obj = GetPotentialNull(false)!;
```

Using the '\!' operator, we "tell" the compiler that the method will definitely not return *null*\. Therefore, there will be no warnings for this code fragment\.

The functionality available when working with nullable reference types is not limited to declaring variables of that type \(using '?'\) and suppressing warnings with '\!'\. Below I'll look at the most interesting features when working with nullable reference types\.

## Working with a nullable context

There are a number of mechanisms for more flexible work with nullable reference types\. Let's look at some of them\.

### Working with attributes

Attributes can be used to tell the compiler the null\-state of various elements\. Let's look at the most interesting ones\. Check out the [documentation](https://learn.microsoft.com/en-us/dotnet/csharp/language-reference/attributes/nullable-analysis) to find the full list of attributes\.

To make it easier, let's introduce the term — null\-state\. The null\-state is information about whether a variable or expression can be *null* at a given time\.

#### AllowNull

Let's look how the attribute work\. Here is an example:

```cpp
public string Name
{
  get => _name;
  set => _name = value ?? "defaultName";
}

private string _name;
```

If you write the *null* value to the *Name* property, the compiler will issue a warning: *Cannot convert null literal to non\-nullable reference type*\. But you can see from the implementation of the property that it can be *null*\. In this case, the "defaultName" string is assigned to the *\_name* field\.

If you add '?' to the property type, the compiler will assume that:

* the set accessor can accept *null* \(this is correct\);
* the get accessor can return *null* \(this is an error\)\.

For correct implementation, it is worth adding the *AllowNull* attribute to the property:

```cpp
[AllowNull]
public string Name
```

After that, the compiler will assume that *Name* may be assigned with *null*, although the property's type is not marked as nullable\. If you assign the value of this property to a variable that should never be *null*, then there will be no warnings\.

#### NotNullWhen

Suppose we have a method that checks a variable for *null*\. Depending on the result of this check, the method returns a value of the *bool* type\. This method informs us about the null\-state of the variable\.

Here's a synthetic code example:

```cpp
bool CheckNotNull(object? obj)
{
  return obj != null;
}
```

This method checks the *obj* parameter for *null* and returns a value of the *bool* type depending on the check result\.

Let's use the result of this method in the condition:

```cpp
public void Foo(object? obj1)
{
  object obj2 = new object();

  if (CheckNotNull(obj1))
    obj2 = obj1;
}
```

The compiler will issue a warning to code above: *Converting null literal or possibly null value to non\-nullable type*\. But such a scenario is impossible, since the condition guarantees that *obj1* is not *null* in the then branch\. The problem is that the compiler doesn't understand this, so we have to help it\.

Let's change the signature of the *CheckNotNull* method by adding the *NotNullWhen* attribute:

```cpp
bool CheckNotNull([NotNullWhen(true)]object? obj)
```

This attribute takes a value of the *bool* type as the first argument\. With *NotNullWhen*, we link the null\-state of the argument with the return value of the method\. In this case, we "tell" the compiler that if the method returns *true*, the argument has a value other than *null*\.

There is a peculiarity associated with this attribute\.

Here are some examples:

**Using the *out* modifier**

```cpp
bool GetValidOrDefaultName([NotNullWhen(true)] out string? validOrDefaultName, 
                           string name)
{
  if (name == null)
  {
    validOrDefaultName = name;
    return true;
  }
  else
  {
    validOrDefaultName = "defaultName";
    return false;
  }
}
```

Here, the compiler will issue a warning: *Parameter 'validOrDefaultName' must have a non\-null value when exiting with 'true'*\. It is quite reasonable, since '==' is used in the condition instead of the '\!=' operator\. In this implementation, the method returns *true* when *validOrDefaultName* is *null*\.

**Using the *ref* modifier**

```cpp
bool SetDefaultIfNotValid([NotNullWhen(true)] ref string? name)
{
  if (name == null)
    return true;

  name = "defaultName";
  return false;
}
```

We will also get a warning for this code fragment: *Parameter 'name' must have a non\-null value when exiting with 'true'*\. Similarly to the previous example, the warning is reasonable\. '==' is used instead of the '\!=' operator\.

**Without using a modifier**

```cpp
bool CheckingForNull([NotNullWhen(true)] string? name)
{
  if (name == null)
    return true;

  Console.WriteLine("name is null");
  return false;
}
```

The situation here is similar to previous cases\. If *name* equals *null*, the method returns *true*\. Following the logic of previous examples, a warning should also be issued here: *Parameter 'name' must have a non\-null value when exiting with 'true'*\. However, there is no warning\. It's hard to say what's caused this, but it looks strange\.

#### NotNullIfNotNull

This attribute allows you to establish a relationship between the argument and the return value of the method\. If the argument is not *null*, the return value is also not *null*, and vice versa\.

Example:

```cpp
public string? GetString(object? obj)
{
  return obj == null ? null : string.Empty;
}
```

The *GetString* method returns *null* or an empty string, depending on the null\-state of the argument\.

Usage of this method: 

```cpp
public void Foo(object? obj)
{
  string str = string.Empty;

  if(obj != null)
    str = GetString(obj);
}
```

Compiler's warning for this code: *Converting null literal or possibly null value to non\-nullable type*\. In this case, the compiler is lying\. Assignment is performed in the body of *if*, the condition of which guarantees that *GetString* will not return *null*\. To help the compiler, let's add the *NotNullIfNotNull* attribute for the return value of the method:

```cpp
[return: NotNullIfNotNull("obj")]
public string? GetString(object? obj)
```

**Note\.** Starting with C\#11, you can get the parameter name using the *nameof* expression*\.* In this case, it would be *nameof\(obj\)*\.

The *NotNullIfNotNull* attribute takes the value of the *string* type as the first argument — the name of the parameter, based on which the null\-state of the return value is set\. Now the compiler has information about the relationship between *obj* and the return value of the method: if *obj* is not *null*, the return value of the method will not be *null,* and vice versa\.

#### MemberNotNull

Let's start with an example:

```cpp
class Person
{
  private string _name;

  public Person()
  {
    SetDefaultName();
  }

  private void SetDefaultName()
  {
    _name = "Bob";
  }
}
```

The compiler will issue a warning to this code fragment:* Non\-nullable field '\_name' must contain a non\-null value when exiting constructor\. Consider declaring the field as nullable*\. However, the *SetDefaultName* method is called in the constructor's body, which initializes the only field of the class\. This means that the compiler's message is false\. The *MemberNotNull* attribute allows you to solve the problem:

```cpp
[MemberNotNull(nameof(_name))]
private void SetDefaultName()
```

This attribute takes an argument of the *string\[\]* type with the *params* keyword\. The strings need to match the names of the members that are initialized in the method\.

Thus, we are indicating that the value of the* \_name* field will not be *null* after this method is called\. Now the compiler can understand that the field is initialized in the constructor\.

#### MemberNotNullWhen

Let's look at the example: 

```cpp
class Person
{
  static readonly Regex _nameReg = new Regex(@"^I'm \w*");

  private string _name;

  public Person(string name)
  {
    if (!TryInitialize(name))
      _name = "invalid name";
  }

  private bool TryInitialize(string name)
  {
    if (_nameReg.IsMatch(name))
    {
      _name = name;
      return true;
    }
    else
      return false;
  }
}
```

*TryInitialize* will initialize *\_name* if the argument's value matches some pattern\. The method returns *true* when the field has been initialized, otherwise it returns *false*\. Depending on the result of executing *TryInitialize*, a value is assigned to the *\_name* field in the constructor\. In this implementation, *\_name* **cannot** be not initialized in the constructor\. However, the compiler will issue a warning: *Non\-nullable field '\_name' must contain a non\-null value when exiting constructor\. Consider declaring the field as nullable*\.

To fix the situation, you need to add the *MemberNotNullWhen* attribute:

```cpp
[MemberNotNullWhen(true, nameof(_name))]
private bool TryInitialize(string name)
```

The type of the first argument is* bool*, the second argument's type is *string\[\]* \(with the *params* keyword\)\. The attribute is used for methods with a return value of the* bool *type\. The logic is simple: if the method returns a value that corresponds to the first argument of the attribute, the class members passed to *params* will be considered initialized\.

#### DoesNotReturn and DoesNotReturnIf

It is not uncommon to have to create methods that throw out exceptions if something has not gone according to plan\. Unfortunately, the compiler cannot always understand that program execution will be terminated after such a method is called\.

Example: 

```cpp
private void ThrowException()
{
  throw new Exception();
}

void Foo(string? str)
{
  if (str == null)
    ThrowException();

  string notNullStr = str;
}
```

For code above, the compiler will issue a warning: *Converting null literal or possibly null value to non\-nullable type*\. However, if *str* is *null*, the execution of the method will not reach the code fragment with the assignment, as an exception will be thrown\. Thus, at the time of assignment, the *str* variable cannot be *null*\.

The *DoesNotReturn* attribute allows you to tell the compiler that after executing the method marked with the attribute, the execution of the calling method stops\.

Let's add the attribute for the *throwException*:

```cpp
[DoesNotReturn]
private void ThrowException()
```

Now the compiler knows that after this method is called, control will not be returned to the calling method\. Therefore, *null* will never be written to *notNullStr*\.

The *DoesNotReturnIf* attribute works similarly to *DoesNotReturn*, except for checking an additional condition\.

Example: 

```cpp
private void ThrowException([DoesNotReturnIf(true)] bool flag)
{
  if(flag)
    throw new Exception();
}
```

The compiler will assume that *throwException* will not return control to the calling method if the *flag* parameter is set to *true*\.

### Specifying context at the project level

To change the nullable context at the project level, you need to open the project properties and select the context in the "Build" section\.


![Image description](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/2xa1xfuncr27gi0c4fly.png)

You can set the nullable context in the project file \(\.csproj\)\. You need to open this file and write the value to the *Nullable* property:

```cpp
<Project Sdk="Microsoft.NET.Sdk">
  <PropertyGroup>
    <OutputType>Exe</OutputType>
    <TargetFramework>net6.0</TargetFramework>
    <ImplicitUsings>enable</ImplicitUsings>
    <Nullable>disable</Nullable>             //<=
  </PropertyGroup>
</Project>
```

It is likely that many people know that you can turn on or turn off the nullable context\. However, there are two more context options\.

#### Warnings

Behavior in the nullable warning context:

* the '?' sign does not affect the analysis in any way;
* from the point of view of the compiler, all values of the reference type can be *null* by default;
* if you write the '?' sign, the compiler will issue a warning that it should not be used in this context;
* the compiler will issue a warning only for those parts of code where the null reference is dereferenced;
* you can indicate that the expression is not *null* using the '\!' operator\.

This mode helps protect against exceptions of *NullReferenceException* type\. The mode informs about the dereference of null reference\.

#### Annotations

Behavior in the nullable annotation context:

* there are no warnings related to dereference of null references and errors when working with nullable reference;
* the compiler does not issue warnings when '?' and '\!' are used\.

This mode helps make a smooth entry into the use of nullable reference types in the project\. It allows you to markup variables that can and cannot be *null*\.

### Working with preprocessor directives

Preprocessor directives are used at the file level with the \.cs extension and allow you to change the states of the nullable context for fragments of code in this file\. The way it works is similar to that described in the previous section\. Each directive starts with '\#'\.

Let's look at all possible directives:

* \#nullable disable – disables the nullable context;
* \#nullable enable – enables nullable context;
* \#nullable restore – restores the nullable context to its value at the project level;
* \#nullable disable annotations – disables annotation context;
* \#nullable enable annotations – enables annotation context;
* \#nullable restore – restores the nullable context to its value at the project level;
* \#nullable disable warnings – disables the warning context;
* \#nullable enable warnings – enables the warning context;
* \#nullable restore warnings – restores the warning context to its value at the project level\.

In fact, the *enable* value represents the enabled context of annotations and the context of warnings, and *disable* – on the contrary, these same contexts are in the disabled state\. So the '\#nullable enable' directive would be equivalent to writing '\#nullable enable annotations' and '\#nullable enable warnings' together\.

You can use multiple directives in one file at once\. This allows you to set a different nullable context for different code fragments\. 

Let's look at an example of such usage \(at the project level, nullable\-context is disabled\):

```cpp
.... // nullable-context is disabled in this code fragment 
#nullable enable warnings
.... // the warning context is enabled in this code fragment
#nullable enable annotations
.... // the context of warnings and annotations is enabled 
     // in this code fragment
#nullable disable annotations
.... // only the warning context is enabled in this code fragment
#nullable restore
.... // nullable-context is disabled in this code fragment  
     // (since the Nullable property – disable)
```

## Conclusion

In conclusion, being able to use nullable reference types should be of great benefit to developers\. These types allow you to make the application more secure and correct from the point of view of architecture\.

This mechanism is not without its drawbacks either\. About drawbacks, and in general about nullable reference types, my colleagues told in articles: [one](https://pvs-studio.com/en/blog/posts/csharp/0631/?utm_source=website&utm_medium=dev_to&utm_campaign=article&utm_content=1017&utm_term=0631), [two](https://pvs-studio.com/en/blog/posts/csharp/0764/?utm_source=website&utm_medium=dev_to&utm_campaign=article&utm_content=1017&utm_term=0764). The ability to add attributes makes sense largely because of the imperfection of the static analyzer\. Therefore, it is necessary to add annotations to methods, fields, etc\. manually, because the analyzer cannot understand some relationships\. For example, the relationship between the return value of a method and the null state of a variable\.

A number of drawbacks are the result of insufficient in\-depth analysis\. Such analysis cannot be done on the fly\. On the other hand, it is not required\. nullable\-context is a good help in the code\-writing process\. When part of the functionality is ready and it needs to be tested, we recommend using tools for deeper analysis – for example, PVS\-Studio\.