Nullable reference types fundamentally changed how C# handles nulls, making your code safer by default.
Let’s see it in action. Imagine a simple Person class:
public class Person
{
public string FirstName { get; set; }
public string LastName { get; set; }
}
public class Program
{
public static void Main(string[] args)
{
Person person = new Person();
// What if we assign null here?
person.FirstName = null;
Console.WriteLine(person.FirstName.Length); // CRASH!
}
}
Without nullable reference types, person.FirstName is implicitly a nullable string. Assigning null is allowed, and accessing .Length on a null string throws a NullReferenceException at runtime. This is the classic, dreaded C# null error.
Now, let’s enable nullable reference types in our project file (.csproj):
<PropertyGroup>
<Nullable>enable</Nullable>
</PropertyGroup>
And update our Person class to explicitly declare non-nullable reference types:
public class Person
{
public string FirstName { get; set; } = string.Empty; // Initialize to non-null
public string LastName { get; set; } = string.Empty; // Initialize to non-null
}
public class Program
{
public static void Main(string[] args)
{
Person person = new Person();
// The compiler will now warn us if we try to assign null
// person.FirstName = null; // Compiler Error CS8601
// Console.WriteLine(person.FirstName.Length); // This line is now safe
}
}
With <Nullable>enable</Nullable>, the compiler enforces nullability at compile time. By default, all reference types are treated as non-nullable. If you try to assign null to person.FirstName, you get a compile-time error (CS8601: Possible null reference assignment). If you try to access a member on a variable that could be null, you get a different error (CS8602: Dereference of a possibly null reference). This shifts null-related bugs from runtime crashes to compile-time warnings that you must fix.
To migrate an existing codebase, you’ll typically follow these steps:
- Enable Nullable Reference Types: Add
<Nullable>enable</Nullable>to your.csprojfile. This is the master switch. - Address Compiler Warnings: The compiler will now flood your output with warnings (mostly
CS8600throughCS8622). The most common ones are:CS8600: Converting null literal or possible null value to non-nullable type.CS8601: Possible null reference assignment.CS8602: Dereference of a possibly null reference.CS8618: Non-nullable property 'X' must contain a non-null value when exiting constructor. Consider declaring the property as nullable.CS8619: Nullability of reference types in value of type 'Y' doesn't match target type 'Z'.CS8620: Argument of type 'A' cannot be used for parameter 'B' of type 'C' due to differences in the nullability of reference types.
The strategy is to systematically resolve these warnings. You have a few primary tools:
- Explicit Nullability (
?): For properties or variables that can legitimately benull, mark them with a?. For example,string? MiddleName { get; set; }. This tells the compiler, "Yes, this can be null, and I’ll handle it." - Initialization: For non-nullable properties (like
FirstNameandLastNamein the example), ensure they are initialized to a non-null value in the constructor or directly in the declaration. If a property must be set by the caller, you might need to add null checks in methods that use it. - Null-Forgiving Operator (
!): Use the!operator to tell the compiler, "I know this might be null, but I’ve checked, and it’s definitely not null here." Use this sparingly and only when you are absolutely certain. For example,Console.WriteLine(person.FirstName!.Length);after you’ve performed a null check elsewhere. NotNullAttributes: For public APIs, especially libraries, use attributes like[NotNull]and[AllowNull]on parameters and return values to provide more granular control over nullability contracts.
A common pattern for migrating is to start by enabling the feature, then focus on fixing CS8618 warnings by initializing all non-nullable properties. Then, address CS8601 and CS8600 warnings by either making variables/properties nullable (?) or ensuring they are assigned non-null values. Finally, tackle CS8602 warnings by adding null checks before dereferencing.
The ? suffix on a type like string? doesn’t change the runtime behavior of the string itself. A string? variable can still hold a null reference, just like a string variable did before nullable reference types were enabled. The difference is purely at compile time: the compiler tracks whether a variable might be null and issues warnings if you try to use it unsafely. The runtime NullReferenceException still exists, but the goal is to eliminate the possibility of it happening by catching it during compilation.
When you have a collection of nullable types, like List<string?>, and you want to filter out the nulls to get a List<string>, you can use LINQ with the null-forgiving operator:
List<string?> nullableStrings = new List<string?> { "hello", null, "world" };
List<string> nonNullableStrings = nullableStrings.Where(s => s != null).Select(s => s!).ToList();
The s != null check ensures we only process non-null strings. Then, s! tells the compiler, "I’ve just checked that s is not null, so it’s safe to treat it as a non-nullable string here." Without the !, Select(s => s) would still yield a List<string?> because the compiler can’t guarantee s is non-null after the Where clause in all scenarios, especially in more complex LINQ chains or when dealing with older C# versions.
The next challenge you’ll encounter is understanding how nullable reference types interact with asynchronous operations and generic types.