Nullable in C#: int?, string? and Managing Null by Design
Working with null in C#: Nullable<T> and int?, lifted operators that compute with null, the nullable reference types of C# 8 and their warnings, flow analysis, the ?. ?? ??= and ! operators, and rules for managing null by design.
Version note: The code in this post uses C# 10 and the .NET 6 APIs; it was built and run with the .NET 10 SDK.
Guess First
We look up a discount coupon based on the customer's email address. If there is no coupon, the method returns null, and we honestly declared its return type as string?. Does this code compile? If it does, what happens when it runs?
string? coupon = FindCoupon("ada@example.com");
Console.WriteLine(coupon.Length);
static string? FindCoupon(string email) => email.EndsWith("@example.org") ? "WELCOME10" : null;
Program.cs(2,19): warning CS8602: Dereference of a possibly null reference.
Unhandled exception. System.NullReferenceException: Object reference not set to an instance of an object.
at Program.<Main>$(String[] args) in /src/Bookstore/Program.cs:line 2
The code compiled; the compiler only gave a warning. And when it ran, it crashed with a NullReferenceException. The most important sentence of this post is hidden in that output: nullable reference types in C# are a compile-time warning system; they do not protect you at run time. If you read the warning, you catch the bug before the code ever runs; if you do not, you catch it in production, just like before C# 8.
Null is the way to say "there is no value here". As we saw in the previous post, a reference type variable can hold a reference that points to no object at all: null. The trouble is that the code using that variable often forgets this. Tony Hoare, who invented the null reference, later called it his "billion-dollar mistake". C# attacks the problem from two sides: Nullable<T> for value types, and nullable reference types for reference types.
Nullable Value Types: int?
An int is always a number; it cannot be null. But sometimes you need to say "no value": the book has no rating yet, no discount is defined on the order. For that you put a ? after the type:
int? discountPercent = null;
Nullable<int> sameThing = null;
Console.WriteLine(discountPercent.HasValue);
Console.WriteLine(discountPercent.GetValueOrDefault());
Console.WriteLine(discountPercent ?? 0);
discountPercent = 15;
Console.WriteLine(discountPercent.HasValue);
Console.WriteLine(discountPercent.Value);
int certain = discountPercent ?? 0;
Console.WriteLine(certain);
Console.WriteLine(sameThing.HasValue);
False
0
0
True
15
15
False
int? is the short form of Nullable<int>; the two are exactly the same type. HasValue tells you whether there is a value, and Value gives you the value. GetValueOrDefault() returns the type's default value (0 here) when there is no value, while ?? lets you choose which value to use. If you read Value when there is no value, you get an exception:
int? rating = null;
int stars = rating.Value;
Program.cs(2,13): warning CS8629: Nullable value type may be null.
Unhandled exception. System.InvalidOperationException: Nullable object must have a value.
at System.Nullable`1.get_Value()
at Program.<Main>$(String[] args) in /src/Bookstore/Program.cs:line 2
If you try to assign an int? directly to an int, the compiler stops you, because that assignment means losing information:
int? rating = null;
int stars = rating;
Program.cs(2,13): error CS0266: Cannot implicitly convert type 'int?' to 'int'. An explicit conversion exists (are you missing a cast?)
Program.cs(2,13): warning CS8629: Nullable value type may be null.
My advice: almost never use Value. Either provide a default with ??, or check first with a pattern like is int rating.
Arithmetic with Null: Lifted Operators
You can do arithmetic and comparisons with int? values, but the results can be surprising at first:
int? stock = null;
Console.WriteLine(stock + 5 is null);
Console.WriteLine(stock > 0);
Console.WriteLine(stock <= 0);
Console.WriteLine(stock == null);
bool? isGift = null;
Console.WriteLine(isGift == true);
True
False
False
True
False
null + 5 gives null: add 5 to an unknown number and the result is unknown too. In comparisons, once null is involved, the result is always false. Note that both stock > 0 and stock <= 0 returned false; a null value is neither positive nor zero or negative. Code you write as if (stock > 0) { ... } else { /* out of stock */ } falls into the "out of stock" branch when null arrives, and you may not notice. When working with bool?, you also have to write if (isGift == true); if (isGift) does not compile.
Nullable Reference Types
Reference types could always be null. The nullable reference types feature from C# 8 turns this around: if the project file has <Nullable>enable</Nullable> (project templates have turned it on since .NET 6), string means "a string that is not null" and string? means "a string that may be null". The compiler then tracks this contract throughout the code:
string title = null;
string? subtitle = null;
Console.WriteLine(GetLength(title));
Console.WriteLine(GetLength(subtitle));
static int GetLength(string text) => text.Length;
Program.cs(1,16): warning CS8600: Converting null literal or possible null value to non-nullable type.
Program.cs(4,29): warning CS8604: Possible null reference argument for parameter 'text' in 'int GetLength(string text)'.
Program.cs(5,29): warning CS8604: Possible null reference argument for parameter 'text' in 'int GetLength(string text)'.
Unhandled exception. System.NullReferenceException: Object reference not set to an instance of an object.
at Program.<<Main>$>g__GetLength|0_0(String text) in /src/Bookstore/Program.cs:line 7
at Program.<Main>$(String[] args) in /src/Bookstore/Program.cs:line 4
CS8600 says you assigned null to a variable that should not be null. CS8604 says you passed a value that may be null to a parameter that does not expect null. The code still ran, and still crashed. At run time there is no difference between string and string?; both are the same System.String. The difference exists only in the compiler's head.
The most common place you will see these warnings is in classes:
var book = new Book();
Console.WriteLine(book.Title.Length);
class Book
{
public string Title { get; set; }
public string Isbn { get; set; }
public string? Subtitle { get; set; }
}
Program.cs(6,19): warning CS8618: Non-nullable property 'Title' must contain a non-null value when exiting constructor. Consider adding the 'required' modifier or declaring the property as nullable.
Program.cs(7,19): warning CS8618: Non-nullable property 'Isbn' must contain a non-null value when exiting constructor. Consider adding the 'required' modifier or declaring the property as nullable.
Unhandled exception. System.NullReferenceException: Object reference not set to an instance of an object.
at Program.<Main>$(String[] args) in /src/Bookstore/Program.cs:line 2
We marked Title and Isbn as not nullable but never gave them a value anywhere; when the constructor finishes, both are null. The required suggestion in the message came with C# 11, and you cannot use it in C# 10; we will see it in the C# Versions series. In C# 10, the fix is to give the property an initial value (= "";), to take the value in the constructor, or to make it string? if the property really can stay empty.
If you want to take the warnings seriously, and I think you should in new projects, you can turn them into errors:
<PropertyGroup>
<TargetFramework>net6.0</TargetFramework>
<Nullable>enable</Nullable>
<WarningsAsErrors>nullable</WarningsAsErrors>
</PropertyGroup>
The same first example no longer compiles:
Program.cs(2,19): error CS8602: Dereference of a possibly null reference.
Flow Analysis and Null Checks
The compiler tracks whether a variable is null by looking at the flow of the code. After you make a check, it stops warning inside that block:
string? coupon = FindCoupon("ada@example.org");
if (coupon is not null)
{
Console.WriteLine(coupon.Length);
}
if (string.IsNullOrEmpty(coupon))
{
return;
}
Console.WriteLine(coupon.ToLowerInvariant());
static string? FindCoupon(string email) => email.EndsWith("@example.org") ? "WELCOME10" : null;
9
welcome10
Inside the if (coupon is not null) block, coupon can no longer be null, and the compiler knows it. The second part is more interesting: we returned if string.IsNullOrEmpty(coupon) was true, and the compiler did not warn on the line below. That is because .NET's own methods carry the information "if this method returns false, the parameter is not null" through attributes. We will see how to use those attributes in your own code in the C# Advanced series.
?. ?? and ??=
The three operators you will use most when working with null:
var customer = new Customer { Name = "Ada" };
Console.WriteLine(customer.Address?.City ?? "no city");
customer.Address = new Address { City = "Izmir" };
Console.WriteLine(customer.Address?.City ?? "no city");
string? nickname = null;
nickname ??= customer.Name;
Console.WriteLine(nickname);
int? length = customer.Address?.City?.Length;
Console.WriteLine(length);
class Customer
{
public string Name { get; set; } = "";
public Address? Address { get; set; }
}
class Address
{
public string? City { get; set; }
}
no city
Izmir
Ada
5
?. (null-conditional) cuts the chain short when the value on its left is null and makes the result null; it does not throw an exception. customer.Address?.City returns null when there is no address, and ?? "no city" provides the value to use in that case. ??= means "if the variable is null, assign this value". Note the last example: Length is an int, but the result of the expression customer.Address?.City?.Length is int?. Because the chain can be cut anywhere, ?. always produces a nullable result.
The ! Operator and When Not to Use It
! (the null-forgiving operator) tells the compiler "I know this is not null, turn off the warning":
string? input = Console.ReadLine();
string trimmed = input!.Trim();
Console.WriteLine(trimmed);
The return type of Console.ReadLine() is string?; it returns null when the input stream ends. input! silenced the warning, and the program worked fine with the input Dune :
Dune
But ! adds no check at all. When the input stream is closed, this code still throws a NullReferenceException; the compiler just no longer warns you. Every place you see ! means "there is an assumption here". Its proper use is narrow: situations where you truly know something the compiler cannot. Most of the time the right fix is not ! but ?? "" or an explicit null check.
default! belongs to the same family and can mislead you:
string title = default!;
Console.WriteLine(title is null);
True
A variable whose type is string, which we said "is never null", holds null, and the compiler says nothing.
Managing Null by Design
Instead of sprinkling ?. and if (x is null) everywhere, it is much healthier to decide up front where null makes sense. There are a few rules I follow. First: check incoming data at the front door. If a constructor does not accept null, it should say so immediately:
var book = new Book("Dune", "978-0441013593");
Console.WriteLine(book.Title);
class Book
{
public Book(string title, string isbn)
{
ArgumentNullException.ThrowIfNull(title);
ArgumentNullException.ThrowIfNull(isbn);
Title = title;
Isbn = isbn;
}
public string Title { get; }
public string Isbn { get; }
public string? Subtitle { get; init; }
}
Dune
ArgumentNullException.ThrowIfNull came with .NET 6. If a null value arrives, the cause of the error shows up right where it happens, before a broken object starts traveling through the system. Subtitle, on the other hand, really is optional, so it is string?.
Second: "not found" and "there are none" are different things. A method that looks for a single thing can return null when it does not find it, and it says so honestly by making its return type Book?. A method that returns a collection, however, should never return null; it should return an empty collection:
var shelf = new Shelf();
Console.WriteLine(shelf.FindByIsbn("978-0441013593")?.Title ?? "not found");
Console.WriteLine(shelf.FindByAuthor("Austen").Count);
foreach (Book book in shelf.FindByAuthor("Herbert"))
{
Console.WriteLine(book.Title);
}
if (shelf.TryGetStock("978-0441013593", out int stock))
{
Console.WriteLine(stock);
}
class Shelf
{
private readonly List<Book> books = new()
{
new Book { Isbn = "978-0441013593", Title = "Dune", Author = "Herbert", Stock = 3 }
};
public Book? FindByIsbn(string isbn) => books.Find(b => b.Isbn == isbn);
public List<Book> FindByAuthor(string author) => books.FindAll(b => b.Author == author);
public bool TryGetStock(string isbn, out int stock)
{
Book? book = FindByIsbn(isbn);
stock = book?.Stock ?? 0;
return book is not null;
}
}
class Book
{
public string Isbn { get; set; } = "";
public string Title { get; set; } = "";
public string Author { get; set; } = "";
public int Stock { get; set; }
}
Dune
0
Dune
3
FindByAuthor("Austen") found no books, but Count returned 0 and foreach ran without trouble. The caller did not have to write a single null check. Third: where a value may be missing and that is normal, the "Try" pattern (TryGetStock) saves you from both null and exceptions.
Common Pitfalls
Forgetting about array elements. Nullable reference types do not track what is inside arrays:
string[] titles = new string[2];
Console.WriteLine(titles[0].Length);
Unhandled exception. System.NullReferenceException: Object reference not set to an instance of an object.
at Program.<Main>$(String[] args) in /src/Bookstore/Program.cs:line 2
string[] says "an array of non-null strings", but new string[2] starts with two nulls and the compiler gave no warning. Fill the array the moment you create it, or make its type string?[].
Ignoring the warnings. If hundreds of nullable warnings have piled up in a project, the important new warning gets lost among them. Keep the warning count at zero, or turn the warnings into errors as shown above.
Silencing warnings with !. A warning points to a question: "can this be null?" ! does not answer that question; it only stops it from being asked.
Accessing Value without a check. Writing rating.Value for an int? carries the same risk as using ! on a reference type. Use ??, GetValueOrDefault() or a pattern.
A Small Experiment
★☆☆ Declare a decimal? discount variable. Calculate the price with price - (discount ?? 0m) and print the results when discount is null and when it is 5.
★★☆ Fix the code from the start of the post in three different ways so that it neither warns nor crashes: with if, with ?. and ??, and with string.IsNullOrEmpty. Which one expresses the intent best?
★★★ Add <WarningsAsErrors>nullable</WarningsAsErrors> to a new console project and write a Book class with string Title, string? Subtitle and List<string> Tags. Get the project to compile without using a single !.
In this post we saw again and again what happens when we forget a null check: the program crashed with an exception. In the next post we take on those exceptions themselves: try, catch and finally, the difference between throw and throw ex, the when filter, writing your own exception type, and when to use an exception and when to use TryXxx.