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.