Decompiling [CallerArgumentExpression]: the source text the compiler bakes into your call sites, and how Glass restores the clean call
When you call ArgumentNullException.ThrowIfNull(x) or a custom Guard.Against(x), you pass one argument. The compiled IL pushes two: your value, and a string literal holding the exact source text of your first argument — because the parameter carries [CallerArgumentExpression]. The attribute lives in metadata on the parameter; the captured text lives as a plain ldstr at every call site. A naive decompiler prints the redundant second argument back at you. This post reads the IL and the attribute blob, shows why the literal is there, and walks how Glass.NET detects the attribute, matches the literal to the reconstructed expression, suppresses the synthesized argument, and rebuilds the method declaration with the attribute intact.
Most compiler features that touch a call site leave behind a shape you can read back: a state machine, a display class, a hidden temporary. [CallerArgumentExpression] is unusual because what it leaves behind is a string of your own source code, frozen into the callee’s arguments at the moment you compiled. Decompile a project that uses ArgumentNullException.ThrowIfNull or a hand-rolled Guard, and without the right handling you get your own argument names echoed back at you as literal strings. This post reads exactly what the compiler emits, and shows how Glass.NET detects the attribute, matches the literal, and restores the call you actually wrote.
Decompilation here is the ordinary, legitimate kind: reading IL to understand, debug, or audit code you wrote or are entitled to inspect.
The feature, and what it compiles to
[CallerArgumentExpression] lets a parameter receive, as a string, the textual expression a caller passed for another argument. The canonical use is in the BCL:
public static void ThrowIfNull(
[NotNull] object? argument,
[CallerArgumentExpression(nameof(argument))] string? paramName = null)
{
if (argument is null)
throw new ArgumentNullException(paramName);
}
Call it with a single argument:
static void Use(Order order) => ArgumentNullException.ThrowIfNull(order.Customer);
You passed one argument. The compiler emits two. The second is the source text of the first, captured verbatim as a literal:
.method private hidebysig static void Use(class Order order) cil managed
{
IL_0000: ldarg.0
IL_0001: callvirt instance class Customer Order::get_Customer()
IL_0006: ldstr "order.Customer" // <- the baked caller-expression
IL_000b: call void [System.Runtime]System.ArgumentNullException::ThrowIfNull(object, string)
IL_0010: ret
}
That ldstr "order.Customer" is the whole feature at the call site. It is not computed at run time, it is not a reference to any symbol — it is a frozen snapshot of the characters you typed between the parentheses, produced by the compiler reading its own syntax tree and rendering the referenced argument back to text. Change the spacing or the member path in source, recompile, and the literal changes with it. Ship a new caller without recompiling and the old literal persists, because the text was copied into the caller, not referenced from the callee.
Where the attribute lives in metadata
The call site only tells half the story. The instruction that it is a caller-expression argument — rather than an ordinary string the author happened to pass — lives on the callee’s parameter, in metadata:
.method public hidebysig static void ThrowIfNull(object argument,
[opt] string paramName) cil managed
{
.param [2]
.custom instance void [System.Runtime]System.Runtime.CompilerServices.CallerArgumentExpressionAttribute::.ctor(string)
= ( 01 00 08 61 72 67 75 6D 65 6E 74 00 00 ) // ...argument..
.param [2] = nullref
...
}
Three facts are encoded on parameter 2. The [opt] flag marks it Optional. The .param [2] = nullref is its default value in the Constant table — a caller-expression parameter is always optional with a default, so that callers who do not want the capture (or who target an older compiler) still bind. And the .custom row is the CallerArgumentExpressionAttribute itself. Decode its blob and you get the constructor argument: 01 00 is the attribute prolog, 08 is the length of the UTF-8 string that follows, 61 72 67 75 6D 65 6E 74 spells argument, and the trailing 00 00 is the (zero) count of named properties. So the attribute says, in effect: “when a caller omits me, fill me with the source text of the parameter named argument.”
This is the pointer a decompiler must follow. The literal at the call site is meaningless on its own — only parameter 2’s attribute tells you that the string is a synthesized capture of argument 1, and therefore a candidate for suppression.
What a naive decompiler prints
A decompiler that walks the call and reads only the operands sees a perfectly ordinary two-argument invocation. It has no reason to treat the second operand as special, so it prints both:
// Naive output — technically faithful, but noisy
ArgumentNullException.ThrowIfNull(order.Customer, "order.Customer");
This is not wrong — recompiling it produces identical IL, because an explicit argument always wins over a caller-expression default. But it is not what anyone wrote, and it is actively misleading: it suggests the author cared about that string, when in fact the compiler synthesized it. Multiply this across a codebase that leans on ThrowIfNull, ArgumentException.ThrowIfNullOrEmpty, and a custom guard library, and every guard clause carries a redundant echo of its own first argument. The output reads like code written by someone who did not trust the feature.
How Glass detects and suppresses the synthesized argument
Glass.NET treats the call site and the callee metadata as two halves of one fact. The recovery runs in four steps, shown below.
First, resolve the callee and read its parameter metadata. Glass finds that ThrowIfNull’s parameter 2 carries CallerArgumentExpressionAttribute("argument"), so the string in position 2 is a candidate synthesized argument tied to position 1.
Second, reconstruct the referenced expression. Glass already has a recovered syntax tree for the call — it knows argument 1 is order.Customer. It renders that argument back to canonical C# text exactly as the compiler would have: member access, indexers, casts, and literals all round-trip to the same spelling the compiler used when it created the baked string.
Third, normalize and compare. The compiler captures source text including whatever insignificant whitespace the author used (ThrowIfNull( order . Customer ) would bake "order . Customer"), while Glass’s regenerated text is canonical. So the matcher normalizes both sides for insignificant whitespace before comparing. When the normalized strings are equal, the literal is the compiler’s own work.
Fourth, suppress or keep. On a match, Glass drops the argument and prints the clean call:
// Glass.NET output
ArgumentNullException.ThrowIfNull(order.Customer);
On a mismatch — the author wrote ThrowIfNull(order.Customer, "the customer"), deliberately overriding the capture, or the first argument is something whose text Glass cannot pin down — the literal is preserved, because dropping it would change meaning. The rule is conservative by design: Glass removes the argument only when it can prove the literal is exactly what the feature would regenerate.
Reconstructing the declaration
Suppressing the call is only half of a faithful decompile; the method declaration has to carry the attribute back, or recompiling the output would not reproduce the capture. Glass reads the .custom row on parameter 2, decodes the length-prefixed constructor string (argument), reads the Optional flag and the nullref default from the Constant table, and re-emits all three:
public static void ThrowIfNull(
object? argument,
[CallerArgumentExpression(nameof(argument))] string? paramName = null)
Glass prefers nameof(argument) over a bare "argument" string when a parameter of that exact name is in scope, matching idiomatic source; it falls back to the literal string if no such parameter exists (for instance if the referenced name does not resolve, which the C# compiler permits). The = null default and the string? nullable annotation come straight from the Constant table and the NullableAttribute respectively, so the reconstructed signature is recompilable as-is.
Trying it with the CLI
The glass CLI makes the before/after visible. Point it at an assembly and a type, and it prints cleaned C# by default:
glass decompile MyApp.dll --type MyApp.Services.OrderValidator
To see the raw, uncleaned form — every synthesized argument left in place — turn the simplification pass off:
glass decompile MyApp.dll --type MyApp.Services.OrderValidator --no-sugar
Running both against a validator full of guard clauses is the quickest way to see what the feature actually costs at the call site: with --no-sugar every guard shows its echoed literal; with the default pass, each one collapses back to the single-argument call you wrote. And decompiling the guard type itself shows the attribute restored on the parameter, so you can read the capture contract directly.
The takeaway
[CallerArgumentExpression] is a clean illustration of a general decompiler principle: the IL is faithful but underspecified. The literal at the call site is real and recompilable, yet it is not source anyone wrote — it is a compile-time copy of source, and only the callee’s parameter metadata reveals that. A decompiler that reads one without the other gives you noise; one that joins them gives you back the call you wrote. Glass.NET draws the line precisely: it suppresses the synthesized argument only when it can prove the literal matches the expression it would regenerate, and it reconstructs the declaration’s attribute from metadata so the recovered source compiles to the same IL it came from.
Try Nebula.NET
Harden your .NET code in minutes — start with the free edition.