Nulls Should Be Exceptional

Introduction
Null reference exceptions are the norm, they are not exceptional. For many developers, handling nothing (null) has become a mundane routine built into the mind's muscle memory. A few "if" statements there, a guard condition here, and yet many developers do not do enough to prevent their boring everyday code from becoming... well... exceptional.
"A billion-dollar mistake"(1), coined by its own designer, Tony Hoare and for good reason. Null has been responsible for a great number of bugs, vulnerabilities and system crashes. It has seeped into the nomenclature of systems despite the fact that null is insidious and unassumingly ambiguous.
What does "null" mean? Null typically means having no value or significance. However, in computer science, null is significant and represents the special value of having no value. How can something that, by definition, should not be significant, impact a great number of systems and software engineers? Despite its significance in computer science and its relatively clear layman's definition, null is ambiguous. Does null mean uninitialized, not applicable, not provided, not existent, not found, or simply forgotten?
Lets take a relatively trivial example of a simple user object and a variable. Lets say we have a User object with three fields, FirstName, MiddleInitial and LastName each a string value. While it might be expected of a person to not have a middle name and thus provide no middle initial to the system, a developer might simply forget to initialize the middle initial variable. If the latter is the case, then a middle initial may never be provided to the system, which can lead to bugs or guards that may never be required.
record User {
public string FirstName { get; set; }
public string MiddleInitial { get; set; }
public string LastName { get; set; }
}
// did we mean to leave out middle initial?
// was the middle initial not provided for this user?
var author = new User {
FirstName = "Mark",
LastName = "Pro"
};
// what happened here? All fields are null
var uninitialized = new User {};
Such a simple example trivially represents how fields may be neglected during initialization of an object. But, we can take this a step further. What if FirstName and LastName are never set, presenting a larger issue with the expressiveness of the object itself? Before we dive a little deeper, we should explore the function aspect of nulls and something that is all too common in a great many codebases I have had the pleasure of working in.
record User {
public string Id { get; set; }
public string FirstName { get; set; }
public string MiddleInitial { get; set; }
public string LastName { get; set; }
}
// UserService Implementation
sealed class UserService {
readonly IDbConnection _dbConnection;
public UserService(IDbConnection dbConnection) =>
_dbConnection = dbConnection;
public User GetUser(string id) {
if (id == null) return null; //<- These null guards pollute their way into our code
try {
var user = _dbConnection.GetById(id); //<- used as an example here
return user;
}
catch {
return null;
}
}
}
//What the consumer sees without reference
var userService = new UserService(db);
var user = userService.GetUser("abc123");
Here we have access to the whole of the user context, which gives us some clues as to when the GetUser function may return null. However, we need to go looking for those clues, as the function is not "expressive" or rather it doesn't clearly state the intent and return of the function. Why is "null" potentially returned from GetUser? Is it because the ID was null when it was passed in, was there an exception when attempting to retrieve the information, or did the response back from the database return nothing?
While it might be tempting to return null as a default for when something goes awry, we have no good way of informing the consuming function that null needs to be accounted for. At least, not in how the GetUser function is currently implemented.
Null Pollution
Nulls are insidious, like plastics that make their way into the environment that we then later have to clean up or deal with, as ignoring the problem could lead to larger consequences. When we encounter code that is not expressive, we must account for possible permutations of that code.
var userService = new UserService();
var user = userService.GetUser("abc123");
var userName =
$"{user.FirstName} {user.MiddleInitial} {user.LastName}";
//^ this will throw a NullReferenceException
//. do something with userName
The above code may throw a NullReferenceException because the GetUser function may return null. Since null is a possible return value for user we must account for its inevitable lack of value.
if (user is not null) {
var userName = $"{user.FirstName} {user.MiddleInitial}. {user.LastName}";
//. do something with userName
}
Despite the attempt to guard against nulls, there is still a potential bug in the code, and that is that the string may not contain the correct output, as either the first name, middle initial, or last name fields may also be null. Now we must account for all these nullable permutations of our code. In the modern .NET era, we have a variety of ways to deal with this permutation. For brevity I will use pattern matching to showcase the better handling of nulls.
var userName =
user switch {
{ FirstName: {}, MiddleInitial: {}, LastName: {} } u =>
$"{u.FirstName} {u.MiddleInitial}. {u.LastName}",
{ FirstName: {}, LastName: {} } u =>
$"{u.FirstName} {u.LastName}",
_ => ""
};
Using pattern matching, we have accounted for the permutations of the user object when constructing a name. Worse still is when a developer decides that an exception needs to be thrown exclusively when providing null, which most of the time is not required. In the case of modern DI, we will receive an exception stating that the object cannot be constructed if not enough information is provided, or the early construction of this object may throw exceptions on at runtime when the object is created, turning a null value passed into a constructor into a runtime error. Which, more than likely, would have still resulted in a runtime error later on, but with less explicit code. That is, either way the null reference would have to be resolved, but it could be done so without the explicit throwing of a null reference exception.
sealed class UserService {
readonly IDbConnection _dbConnection;
public UserService(IDbConnection dbConnection) =>
_dbConnection =
(dbConnection ?? throw new ArgumentNullException(nameof(dbConnection)));
}
Microsoft has taken it upon themselves to combat the boilerplate pollution that nulls problematically propagate by creating a plethora of operators to help handle unwieldy nulls.
//What if a value is null and if so you want to reassign it?
user ??= new User();
//What if a field is null and you would like to propgate the null
var firstName1 = user?.FirstName;
//What if the field is null and you would like to provide an alternative
var firstName2 = user?.FirstName ?? "Mark";
//Upcomming in dotnet 10 to replace the following
user?.FirstName = "Mark";
Moving Towards Something Better
After a substantial number of versions of C#, Microsoft has generously provided developers with the means to enable and support Nullable Reference Types (NRTs). These are type markers that allow for a bit of expressiveness when defining code. To enable them, simply add <Nullable>enable</Nullable> to your project file under PropertyGroup. Upon doing so, the compiler takes on some of the work of null checking. However, the output from the compiler is simply a warning and can be ignored. There are other instances where nullable reference types can be overridden.
sealed class UserService {
readonly IDbConnection _dbConnection;
public UserService(IDbConnection dbConnection) =>
_dbConnection = dbConnection;
public User? GetUser(string id) {
if (id == null) return null; //<- These null gaurds pollute their way into our code
try {
var user = _dbConnection.GetById(id); //<- used as an example here
return user;
}
catch {
return null;
}
}
}
Given that NRTs are enabled, adding the ? operator to the GetUser function indicates the function returned result may be null, telling the compiler that a warning should be surfaced when it detects that not all potential permutations of null are accounted for. Nullable reference types move us a bit towards expressive code, but are still easy to ignore and shut off by providing using the ! or !. operator to silence any null warnings surfaced by the compiler.
var userName = $"{user!.FirstName} {user!.LastName}"; //<- silence compiler warning
Expressing Concerns
With all these built-in features for handling null, you’d think Microsoft would have provided a better way. They have with F#, but most mere mortal developers will never reach that far. Until we get fully baked discriminated unions(2) in .NET we have to make do with third-party libraries or build our own unions (for now). Wait… what is a discriminated union, you ask? A discriminated union is a way of saying that a value can be one of many possibilities, much like object inheritance “tags” an object or type to belong to a “family” of types.
To represent null, we can make our own simplistic discriminated union called Option. The idea is that Option<T> will either represent some value or none, allowing us to explicitly express that a function may or may not return a value to the caller.
public abstract record Option<T> {
public sealed record Some : Option<T> {
readonly T _value;
public Some(T value) {
ArgumentNullException.ThrowIfNull(value, nameof(value));
_value = value;
}
public T Unwrap => _value;
}
public sealed record None : Option<T>;
}
public static class Option {
public static Option<T>.Some Some<T>(T value) =>
new Option<T>.Some(value);
public static Option<T>.None None<T>() =>
new Option<T>.None();
public static Option<T> Optional<T>(T value) =>
value switch {
{} v => Some(v),
_ => None<T>()
};
public static R Match<T, R>(this Option<T> option, Func<T, R> some, Func<R> none) =>
option switch {
Option<T>.Some v => some(v.Unwrap),
_ => none()
};
}
The above represents a simplistic, expressive Option type which can be reused and extended to support more features, or you can use the one provided in LanguageExt.Core. Let's bring this back to the user service class example and see how the GetById function can now be done a bit more expressively by returning Option<User> as opposed to returning a User which may or may not be null. By specifying Option<User> the consumer is informed that User may or may not exist.
sealed class UserService {
readonly IDbConnection _dbConnection;
public UserService(IDbConnection dbConnection) =>
_dbConnection = dbConnection;
public Option<User> GetUser(string id) {
if (id == null) return Option.None<User>();
try {
var user = _dbConnection.GetById(id); //<- used as an example here
return Option.Some(user);
}
catch {
return Option.None<User>();
}
}
}
Now the consumer code must account for either some value or none (nothing).
var userService = new UserService();
var user = userService.GetUser("abc123");
var userName =
user.Match(
user => $"{user.FirstName} {user.MiddleInitial} {user.LastName}",
() => string.Empty
);
That simple shift in design; from returning null to returning something expressive, highlights the larger point of this article.
Conclusion
Null has been with us for decades, and for just as long developers have been patching around it with guards, defaults, and boilerplate. The problem is not that null exists, but that it is ambiguous. A null can mean too many things at once, and that ambiguity leaks into our designs, our APIs, and eventually our runtime errors.
C# gives us tools to work with null: nullable reference types, pattern matching, and null-coalescing operators. These help, but they mostly treat null as an inevitability to be managed. What changes the story is when we design for absence directly. Modeling “something or nothing” with an Option<T> or another union type makes absence part of the type system instead of leaving it as a hidden runtime hazard.
This shift makes our code more expressive. A function that returns Option<User> communicates its intent far more clearly than one that returns User but sometimes null. Consumers of your code know what to expect, the compiler helps enforce it, and your intent stays visible.
Nulls should be exceptional, not routine. By adopting more expressive patterns like Option<T>, discriminated unions, or other evolving features of C#, we can write code that is safer, more predictable, and less haunted by the billion-dollar mistake.




