You write a guard clause. It throws. The exception message says “Value cannot be null. Parameter name: input.” Cool. But what was the input? What expression did the caller pass? Was it user.Email? config.GetSection("Auth").Value? You don’t know without digging through the stack trace.
[CallerArgumentExpression] captures the literal source text of an argument at compile time. Your error messages suddenly quote the exact code that violated the precondition.
The Basics
using System.Runtime.CompilerServices;
public static class Guard{ public static void NotNull<T>( T? value, [CallerArgumentExpression(nameof(value))] string? expression = null) where T : class { if (value is null) throw new ArgumentNullException(expression, $"'{expression}' must not be null."); }
public static void MustBePositive( int value, [CallerArgumentExpression(nameof(value))] string? expression = null) { if (value <= 0) throw new ArgumentOutOfRangeException(expression, $"'{expression}' must be positive, but was {value}."); }}What Callers See
var order = GetOrder();Guard.NotNull(order.Customer);// Throws: "'order.Customer' must not be null."
Guard.MustBePositive(order.LineItems.Count - discountItems);// Throws: "'order.LineItems.Count - discountItems' must be positive, but was -2."The compiler fills in the expression order.Customer or order.LineItems.Count - discountItems as a string literal at the call site. Zero runtime cost — it’s baked into the compiled IL.
Custom Preconditions
This really shines for domain-specific validation:
public static class Require{ public static void That( bool condition, [CallerArgumentExpression(nameof(condition))] string? expression = null) { if (!condition) throw new InvalidOperationException( $"Precondition failed: {expression}"); }}Require.That(user.Age >= 18);// Throws: "Precondition failed: user.Age >= 18"
Require.That(startDate < endDate);// Throws: "Precondition failed: startDate < endDate"
Require.That(retryCount <= maxRetries);// Throws: "Precondition failed: retryCount <= maxRetries"No more writing custom error messages for every single check. The expression is the message.
How It Works
The attribute tells the compiler: “Take whatever source text the caller passed for the parameter named X, and pass it as a string to this parameter.” It’s resolved at compile time, so there’s no reflection, no Expression trees, no runtime parsing. The string is just… there.
Key Takeaway
[CallerArgumentExpression] makes guard clauses self-documenting with zero effort. Your exception messages quote the exact failing expression from the caller’s source code. No more guessing what “Parameter ‘value’ was invalid” actually means at 2 AM.