Decompiling extension methods: the [Extension] attribute is the only trace, the receiver was just the first argument, and the call that looks static is not
An extension method has no runtime existence as an extension. It is a plain static method whose first parameter happens to be marked, and a call to it — text.Trim().Count() — compiles to an ordinary static call, Enumerable.Count(string.Trim(text)), with no dot-syntax, no receiver, nothing to say it was ever written fluently. The one thing the compiler leaves behind is an [Extension] attribute: on the method, so the first parameter can be rendered with this, and on the containing static class, so a tool knows to look there at all. A decompiler has to read that attribute, decide that a static call whose target carries it should be printed as a member call on its first argument, and get the precedence right so a chain of ten LINQ operators comes back as a chain and not a ten-deep nest of static calls. This post walks the IL for a defined extension method and for a call site, shows what Glass.NET reconstructs, explains why the attribute — not the call — is the source of truth, and covers the newer C# 14 extension-block shape whose members compile to the same static methods under a different skin.
An extension method is one of the purest examples of C# sugar that evaporates in IL. You write this on the first parameter, call it as text.WordCount(), and feel like you added a method to string. The runtime sees none of that. There is a static method, there is a static call, and there is one attribute — [Extension] — left behind like a note that says this could have been written with a dot. A decompiler’s whole job here is to read that note and decide to print the dot, then get the precedence right so a ten-operator LINQ pipeline comes back as a pipeline and not a ten-deep nest. This post walks it for Glass.NET.
The method: a static method wearing one attribute
Start with the definition. A trivial extension on string:
public static class StringExtensions
{
public static int WordCount(this string s)
=> s.Split((char[]?)null, StringSplitOptions.RemoveEmptyEntries).Length;
}
Compile it and look at what this became. It is gone from the parameter — s is an ordinary string parameter — and in its place are two [Extension] attributes, one on the method and one on the class:
.class public abstract sealed auto ansi beforefieldinit StringExtensions
extends [System.Runtime]System.Object
{
.custom instance void [System.Runtime]System.Runtime.CompilerServices.ExtensionAttribute::.ctor() = ( 01 00 00 00 )
.method public hidebysig static int32 WordCount(string s) cil managed
{
.custom instance void [System.Runtime]System.Runtime.CompilerServices.ExtensionAttribute::.ctor() = ( 01 00 00 00 )
// ... ldarg.0; call String::Split(...); ldlen; ...
}
}
That is the entire difference between an extension method and a static helper. The method is public static, the first parameter is a normal parameter of a normal type, there is no special calling convention. The attribute on the method is what lets a decompiler render the first parameter as this s; the attribute on the class is what tells a consuming compiler the class is worth scanning for extensions in the first place. Strip both and you have StringExtensions.WordCount(x) and nothing else; add them and x.WordCount() becomes legal too.
The call site: byte-identical to a static call
Now call it two ways and compare the IL:
int a = "the quick brown fox".WordCount(); // fluent
int b = StringExtensions.WordCount("the quick brown fox"); // explicit static
Both compile to the same three instructions — load the string, call the static method, store:
ldstr "the quick brown fox"
call int32 StringExtensions::WordCount(string)
stloc.0
There is no second form. The CLR has no concept of an extension call, so the this is resolved and discarded at compile time, and the call site keeps no record of which syntax the author used. This is the crux of the whole problem: a decompiler cannot learn from the call whether the source was fluent. It can only look at the method being called, see [Extension] on it, and choose to print the call as a member access on its first argument — "the quick brown fox".WordCount() — because that is the idiomatic form and almost always the original. Both renderings are legal and compile identically, so the choice is readability, not correctness. Glass.NET makes it, which is why the output reads like C# a human wrote rather than like a macro expansion.
The chain: one rule, applied outward
The reason the dot matters is LINQ, where reading the IL literally produces something correct and unreadable. Take a pipeline:
var top = people.Where(p => p.Active).Select(p => p.Name).OrderBy(n => n).ToList();
Every one of Where, Select, OrderBy, ToList is a static extension method on Enumerable, so the IL is a nest — the outermost call emitted last, each result feeding the next call’s first argument:
call ...ToList( call ...OrderBy( call ...Select( call ...Where(people, <lambda>), <lambda>), <lambda>))
Read that literally and you get Enumerable.ToList(Enumerable.OrderBy(Enumerable.Select(Enumerable.Where(people, …), …), …)) — a four-deep nest you have to read inside-out. Because each method carries [Extension], Glass.NET applies the one rule repeatedly: print each static call as a member access on its first argument. The innermost Where(people, …) becomes people.Where(…); that whole expression is the first argument of Select, so it reads people.Where(…).Select(…); and so on outward until the nest has unrolled into the original chain. The precedence has to be exact — a lambda body with a conditional, or a cast inserted mid-chain, needs the right parentheses or the meaning shifts — but the shape falls straight out of applying “member access on the first argument” from the innermost call out.
Why the attribute, not the call, is the source of truth
It is worth being precise about what Glass trusts. The call site is ambiguous by construction — it is the same IL for both syntaxes — so it carries no signal. The method’s metadata is unambiguous: [Extension] is present or it is not. So Glass keys its decision on the method:
- Target method has
[Extension]→ render the call asarg0.Method(rest). - Target method does not → render it as
Type.Method(args).
This also handles the awkward real cases correctly. If someone deliberately wrote Enumerable.Where(list, p) in explicit static form, Glass still prints list.Where(p) — which is fine, because it compiles back to the same IL and reads better. And a generic extension method like Select<TSource, TResult> carries the attribute just the same; Glass renders src.Select(f) and lets C#‘s type inference recover the type arguments at recompile, only spelling them out when inference cannot. The attribute is a reliable, local signal on the method being called, which is exactly what a decompiler wants — no global analysis, no guessing from surrounding using directives that are not even in the IL.
The C# 14 extension-block shape
Newer code may use extension blocks, which group members under one receiver and, for the first time, allow extension properties:
public static class StringExtensions
{
extension(string s)
{
public int WordCount => s.Split((char[]?)null, StringSplitOptions.RemoveEmptyEntries).Length;
public bool IsBlank() => string.IsNullOrWhiteSpace(s);
}
}
This is new syntax, not new runtime. The members still lower to static methods on the enclosing static class, marked so a consumer’s compiler can route name.WordCount and name.IsBlank() to them — an extension property becomes a static accessor method, an extension method a static method with the receiver as its first parameter, plus grouping metadata tying several members to one receiver. A decompiler reading such an assembly sees static methods with marker metadata and a receiver parameter, much as before. Glass.NET reconstructs them as extensions; a plain extension method can be shown either as the classic this-parameter form or inside a block, but an extension property has no classic spelling, so it comes back as a block. The semantics are identical either way, because the lowered static methods are identical — the skin changed, not the IL.
The takeaway
An extension method leaves exactly one trace in metadata: the [Extension] attribute, on the method so the first parameter can wear this, and on the class so a compiler knows to look there. The call site keeps no trace at all — text.WordCount() and StringExtensions.WordCount(text) are byte-identical IL — so a decompiler cannot learn the fluent form from the call and must read it off the method instead. Glass.NET does exactly that: it prints any static call whose target carries [Extension] as a member access on the first argument, applies that one rule from the innermost call outward to unroll a LINQ nest back into a chain, and recognises the C# 14 extension-block lowering as the same static methods under a newer syntax. The result reads like the pipeline that was written, not the nest the compiler left — because the decompiler trusted the attribute, which is the only thing that survived.
Try Nebula.NET
Harden your .NET code in minutes — start with the free edition.