How C# 10.0 and .NET 6.0 improve ArgumentExceptions
C# methods often start by checking their arguments. A new language feature added in C# 10.0 has made it possible for .NET 6.0 to add some helpers that better support this, enabling you to simplify these checks, while also ruling out a common mistake.
Null argument checks before C# 10.0
C# developers are accustomed to seeing this sort of thing:
public bool DoSomething(string id, string name, string favouriteColour)
{
if (id is null)
{
throw new ArgumentNullException(nameof(id));
}
if (name is null)
{
throw new ArgumentNullException(nameof(id));
}
if (favouriteColour is null)
{
throw new ArgumentNullException(nameof(favouriteColour));
}
// ... now go on to do something useful
}
Public methods often begin with a series of tests to detect unacceptable null
inputs, or similar mistakes. Even if you use the Nullable Reference Types (NRT) features introduced in C# 8.0 to enlist the compiler's help in avoiding unwanted nulls, you still need checks like these because NRTs make no guarantees. (For example, the code calling one of your library functions might have been compiled with the NRT feature disabled. The .NET SDK leaves this feature disabled by default, and it was only with .NET 6.0 that .NET development tools commonly started enabling this in new projects.)
There are a couple of problems with this. First, it's verbose. Depending on your code style preferences, you might be able to mitigate that by moving the throw
onto the same line as the if
, and losing the braces, but it's still a bit of a mess.
The second problem is that it's easy to make a particular mistake. I've made that mistake here: did you spot it? My second throw
passes the wrong argument name. So if somebody passes a null
for name
, I'll throw an exception erroneously complaining that they passed a null
id
. This issue is slightly less bad than it was a few years ago, thanks to nameof
: at least if I rename an argument by refactoring it, this sort of reference to its name will automatically pick up the change (and if I rename it without using a refactoring, I'll get a compiler error where I used nameof
with the old name). But that doesn't help me if I referred to entirely the wrong argument.
Null argument checks in C# 10.0 and .NET 6.0
Let's see how it looks in C# 10.0 on .NET 6.0:
public bool DoSomething(string id, string name, string favouriteColour)
{
ArgumentNullException.ThrowIfNull(id);
ArgumentNullException.ThrowIfNull(name);
ArgumentNullException.ThrowIfNull(favouriteColour);
// ... now go on to do something useful
}
There's no doubt that this is more compact. And I find that the resulting layout makes it much easier to see at a glance which arguments I'm dealing with here (although that's subjective, of course).
There's also no longer any opportunity to make the mistake my first example made with the name
argument: I can't check one argument for null
, and then throw an exception that accidentally points the finger at a completely different argument. This is impossible here because I only name each argument once.
But how does this work? How is ThrowIfNull
going to discover the argument name? I've passed it the value of the argument, but it's not obvious how, at runtime, it's going to determine the name to use when throwing an exception. And yet it works exactly how you'd hope. How?
The CallerArgumentExpression attribute
The ThrowIfNull
helper is able to discover the argument name thanks to a new feature in C# 10.0: support for the CallerArgumentExpression
attribute. (This type has been in the .NET class library since .NET 5.0, but the compiler only started supporting it in C# 10.0. The ThrowIfNull
helper also was added in .NET 6.0.) Here's how the declaration of ThrowIfNull
looks:
public static void ThrowIfNull(
[NotNull] object? argument,
[CallerArgumentExpression("argument")] string? paramName = null)
This has a second, optional argument, annotated with the [CallerArgumentExpression]
attribute. This attribute takes a single argument that is required to identify some other parameter (and the C# compiler will tell you if the name you've used doesn't match any of your parameter names). This works in a similar way to longer-established attributes such as [CallerMemberName]
and [CallerLineNumber]
: when a method has one of these attributes on an optional parameter, and when some code calls that method without supplying an explicit value for that parameter, the C# compiler generates code that supplies the relevant contextual information. With [CallerMemberName]
, it supplies the name of the method or property from which the call is being made. And with this new [CallerArgumentExpression]
it provide the text of the expression that was used to supply the argument value. So in this case those first three lines effectively become this:
ArgumentNullException.ThrowIfNull(id, "id");
ArgumentNullException.ThrowIfNull(name, "name");
ArgumentNullException.ThrowIfNull(favouriteColour, "favouriteColour");
The [NotNull]
on the first argument is not strictly on-topic, but worth discussion because its behaviour is slightly non-obvious. Why is the argument declared as nullable (object?
rather than object
) and yet marked as [NotNull]
? This attribute expresses a post-condition: it says that if this method returns, then the compiler can safely deduce that argument
was not null
. I blogged about this in detail at https://endjin.com/blog/2020/06/dotnet-csharp-8-nullable-references-notnull but the essence is that any code following a call to ThrowIfNull
will treat its input as being non-null even if the argument or variable in question was declared as nullable.
Other uses for CallerArgumentExpression
If you read the original design documentation for CallerArgumentExpression
, it leads with a different use case: improved assertions. They show an example based on Debug.Assert
:
Debug.Assert(array != null);
Debug.Assert(array.Length == 1);
The problem with this code, as they explain, is that at runtime you get no indication of which of these two assertions failed. The Debug.Assert
method being used here just takes a bool
, so at runtime all it can do is tell you that something failed; it can't tell you what failed.
The thinking was that [CallerArgumentExpression]
would make it possible for the assertion to provide more useful information, because those calls could be turned into this:
// NOTE: doesn't actually work like this with the real Debug.Assert
Debug.Assert(array != null, "array != null");
Debug.Assert(array.Length == 1, "array.Length == 1");
This underlines the fact that the compiler handles this attribute by passing the text of the expression that was used for the argument. With ThrowIfNull
, the assumption is that this will be just the argument on its own. But here, the point is that it might be more complex.
In practice, Debug.Assert
has not been updated to support this in .NET 6.0, mainly because there's no way to add it without it being a breaking change. (A new overload would not constitute a binary breaking change, but it would change the interpretation of most calls to Debug.Assert
in existing code.)
But it seems likely that unit testing libraries and assertion libraries will adopt this to provide better reporting of test failures.