This post is for the C# developers out there and takes a look at the interesting conjunction of [CallerArgumentExpression]
and static extension methods – a mix that at first seems too convenient to pass up.
A quick recap: [CallerArgumentExpression]
landed as part of the C# 10.0 language update and helps to reduce the (often brittle!) boilerplate involved in, among other uses, creating useful error messages capturing the names of variables or the text of expressions. You tag an optional string
method parameter with [CallerArgumentExpression("argName")]
where argName
is the name of the method argument you want stringified, and the compiler does the rest.
Here’s a quick demo of how [CallerArgumentExpression]
works:
using System;
using System.Runtime.CompilerServices;
public class Program
{
static string Stringify(object obj,
[CallerArgumentExpression("obj")] string expr = "")
{
return expr;
}
public static class Foo
{
public string Bar = "bar";
}
public static void Main()
{
var expr = Stringify(Foo.Bar);
Console.WriteLine(expr); // prints "Foo.Bar"
expr = Stringify(Foo.Bar + Foo.Bar);
Console.WriteLine(expr); // prints "Foo.Bar + Foo.Bar"
}
}
And you can try it online yourself in this .NET Fiddle.
It’s really cool and it opens the door to a lot of possibilities (though I’m still stuck trying to figure some of them out, such as reliably setting/clearing model binding errors that involve array expressions).
As mentioned, this shipped with C# 10. And of course, C# 8 shipped “the big one:” nullable reference types. Since then, the following pattern has become familiar in many a codebase while devs figure out where variables actually can or can’t be null:
using System;
using System.Diagnostics.CodeAnalysis;
using System.Runtime.CompilerServices;
static class Extensions
{
public static T ThrowIfNull<T>([NotNull] T? value, string expr)
{
if (value is null) {
throw new ArgumentNullException(expr);
}
return value;
}
}
This does exactly what you think it does: it verifies that a value isn’t null or throws an exception if it is. And it lets the compiler know that downstream of this call, the passed-in value is non-null
. To make it useful, it’s common enough to extend it with more caller attribute magic:
using System;
using System.Diagnostics.CodeAnalysis;
using System.Runtime.CompilerServices;
static class Extensions
{
public static T ThrowIfNull<T>(
[NotNull] T? value,
string expr,
[CallerMemberName] string callerName = "",
[CallerFilePath] string filePath = "",
[CallerLineNumber] int lineNumber = 0)
{
if (value is null) {
throw new InvalidOperationException(
$"{expr} unexpectedly null in {callerName} "
+ $"at {filePath}:{lineNumber}");
}
return value;
}
}
Now we get useful exceptions that we’ll hopefully log and revisit to help us find any places in our codebase where we are assuming a value can’t be null
but it turns out that, in fact, it can be.
But what about if we try to add our new best buddy [CallerArgumentExpression]
here, to get rid of the need to manually specify the text of the argument via argName
in our ThrowIfNull()
?
using System;
using System.Diagnostics.CodeAnalysis;
using System.Runtime.CompilerServices;
static class Extensions
{
public static T ThrowIfNull<T>(
[NotNull] T? value,
[CallerArgumentExpression("value")] string expr = "",
[CallerMemberName] string callerName = "",
[CallerFilePath] string filePath = "",
[CallerLineNumber] int lineNumber = 0)
{
if (value is null) {
throw new InvalidOperationException(
$"{expr} unexpectedly null in {callerName} "
+ $" at {filePath}:{lineNumber}");
}
return value;
}
}
At first blush, this works great. Use it with a single variable directly, as in foo.ThrowIfNull()
, and everything will work swimmingly and it’ll do exactly what it says on the tin. But try using it in a more-complicated setting, say like foo?.bar?.ThrowIfNull()
, and you’ll see what I mean: here, argName
will only capture the last token in the chain and you’ll see that argName
is only bar
and not foo.bar
!
It’s actually not particularly surprising behavior. Even without knowing what Roslyn desugars the above code to, you could logically think of it as being an expression (conditionally) invoked on/with the final variable bar
itself – after all, T
here would have been bar.GetType()
, so it’s not a huge stretch of the imagination to guess that expr
might only span bar
as well.1
Indeed, when you look at what the code compiles to, you’ll see why. For the following code fragment:
public class Foo {
public string? Bar;
}
public class C {
public void M(Foo? foo) {
foo?.Bar.ThrowIfNull();
}
}
public class Foo
{
[System.Runtime.CompilerServices.Nullable(2)]
public string Bar;
}
public class C
{
[System.Runtime.CompilerServices.NullableContext(2)]
public void M(Foo foo)
{
if (foo != null)
{
Extensions.ThrowIfNull(foo.Bar, ".Bar");
}
}
}
Which, while still helpful, is not exactly what we want. Although as C# developers we are somewhat allergic to calling static helper utilities directly instead of cleverly turning them into their more ergonomic extension method counterparts, in this we don’t have any other choice.
When we change ThrowIfNull()
from an extension method to a regular static method though, we get the result we really wanted:
public static class Utils
{
public static T ThrowIfNull<T>(
[NotNull] T? value,
[CallerArgumentExpression("value")] string? expr = null)
{
if (value is null) {
throw new ArgumentNullException(expr);
}
return value;
}
}
public class Foo
{
public string? Bar;
}
public class C
{
public void M(Foo? foo)
{
Utils.ThrowIfNull(foo?.Bar);
}
}
public class C
{
[System.Runtime.CompilerServices.NullableContext(2)]
public void M(Foo foo)
{
Utils.ThrowIfNull((foo != null) ? foo.Bar : null, "foo?.Bar");
}
}
Liked this post? Follow me on twitter @mqudsi and like this tweet for more .NET awesomeness!
Do you know how to effectively use [CallerArgumentExpression] to supercharge C# @dotnet codebase? Here's the number one mistake to look out for. https://t.co/sReLDSS3iw
— Mahmoud Al-Qudsi (@mqudsi) September 11, 2023
Except
expr
is actually notbar
but rather.bar
! ↩