DEV Community

Zero Heartbeat
Zero Heartbeat

Posted on Originally published at delta1labs.com

Decompiling C# string interpolation and interpolated string handlers

An interpolated string is one of the most innocent-looking pieces of C# you can write — $"Hello {name}, you have {count} messages" — and one of the least predictable once it reaches IL. There is no interpolation instruction. The compiler rewrites every $"..." into ordinary method calls, and it has three completely different ways of doing so: a string.Concat, a string.Format with a positional template, or — on modern targets — a construct-and-append sequence against DefaultInterpolatedStringHandler. Which one it picks depends on your target framework, the types in the holes, and whether the receiving method opted into a handler of its own. Walking each lowering explains what a decompiler such as Glass.NET can fold back into $"...", and where it can only show you the machinery the compiler actually emitted.

Three lowerings, one syntax

The single most useful fact about $"..." is that the syntax tells you almost nothing about the IL. The compiler looks at where the string is going and what is in the holes and chooses:

The rest of this post is those four boxes, in order, with the IL each one produces and the fingerprint a decompiler uses to recognize it.

The concat shortcut

If every hole in the string is already a string, and no hole has an alignment or format specifier, the compiler does the cheapest possible thing: it calls String.Concat. Given

string Greet(string first, string last) => $"{first} {last}";
Enter fullscreen mode Exit fullscreen mode

the body is simply a three-argument concat — the literal space is just another operand:

ldarg.1              // first
ldstr      " "
ldarg.2              // last
call       string System.String::Concat(string, string, string)
ret
Enter fullscreen mode Exit fullscreen mode

This is byte-for-byte identical to what first + " " + last compiles to, and identical to a hand-written string.Concat(first, " ", last). There is no marker that says "this was interpolation." A decompiler is free to render it as $"{first} {last}", as first + " " + last, or as the explicit concat — all three are faithful readings of the same IL. The interpolation, as a distinct idea, did not survive compilation.

The format template

The moment a hole is not a string — a number, a DateTime, anything that needs formatting — the classic lowering is String.Format with a positional template. This is what you get on netstandard2.0, on .NET Framework, and on any target that predates the handler type. For

string Status(int count, double rate) => $"{count} items at {rate:P1}";
Enter fullscreen mode Exit fullscreen mode

the compiler builds the template string, boxes the value-type arguments into an object[], and calls Format:

ldstr      "{0} items at {1:P1}"
ldc.i4.2
newarr     [System.Runtime]System.Object
dup
ldc.i4.0
ldarg.1              // count
box        [System.Runtime]System.Int32      // int -> object
stelem.ref
dup
ldc.i4.1
ldarg.2              // rate
box        [System.Runtime]System.Double     // double -> object
stelem.ref
call       string System.String::Format(string, object[])
ret
Enter fullscreen mode Exit fullscreen mode

Two things are worth noticing here, because they are exactly what you see in the decompiled output. First, your format specifier :P1 is preserved verbatim inside the template as {1:P1} — the decompiler can read it straight out of the ldstr. Second, the value types are boxed; the object[] is the allocation cost this lowering carries, and it is the reason the newer handler form exists.

Because this is a literal String.Format call, it is indistinguishable from one you wrote by hand. A decompiler will show one of the two. Many print the string.Format("{0} items at {1:P1}", count, rate) call as-is, because it is the lower-level, more portable reading; a C#-aware decompiler may re-sugar it to $"{count} items at {rate:P1}" by parsing the template and mapping {0},{1} back onto the argument order. Both are correct.

The handler dance

On .NET 6 and later, a plain assignment of an interpolated string to a string no longer goes through String.Format. The compiler lowers it against DefaultInterpolatedStringHandler, a ref struct in System.Runtime.CompilerServices that accumulates the result into an internal buffer — no template parsing, no object[], no boxing of the arguments into the array. The same Status method on a modern target decompiles to this shape:

string Status(int count, double rate)
{
    DefaultInterpolatedStringHandler handler =
        new DefaultInterpolatedStringHandler(9, 2); // literalLength=9, formattedCount=2
    handler.AppendFormatted(count);
    handler.AppendLiteral(" items at ");
    handler.AppendFormatted(rate, "P1");
    return handler.ToStringAndClear();
}
Enter fullscreen mode Exit fullscreen mode

Read the constructor arguments, because they are the first half of the fingerprint. The handler is created with (int literalLength, int formattedCount) — the total number of literal characters (" items at " is nine, plus any other literal runs) and the number of holes. The handler uses those to size its buffer up front. Then the body is a strict interleaving: one AppendLiteral per run of literal text, one AppendFormatted per hole, in source order, finished by exactly one ToStringAndClear() that produces the string and returns the buffer. In IL it is a sequence of call/callvirt against a local of the handler type:

ldloca.s   handler
ldc.i4.s   9
ldc.i4.2
call       instance void DefaultInterpolatedStringHandler::.ctor(int32, int32)
ldloca.s   handler
ldarg.1
call       instance void DefaultInterpolatedStringHandler::AppendFormatted<int32>(!!0)
ldloca.s   handler
ldstr      " items at "
call       instance void DefaultInterpolatedStringHandler::AppendLiteral(string)
...
ldloca.s   handler
call       instance string DefaultInterpolatedStringHandler::ToStringAndClear()
ret
Enter fullscreen mode Exit fullscreen mode

That literal-length-and-count constructor, the alternating AppendLiteral/AppendFormatted<T> calls on a single handler local, and the terminating ToStringAndClear are a shape nothing else produces. A modern decompiler recognizes it and folds the whole block back to $"{count} items at {rate:P1}". A decompiler that does not — or that is reading output where the optimizer hoisted or reordered some of the appends — shows the handler calls literally. That is not a failure: it is the honest reading, and it is often more informative than the sugar, because it shows you exactly where the buffer allocation and each AppendFormatted<T> generic instantiation happen.

Alignment, format, and the culture trap

The AppendFormatted family has several overloads, and which one the compiler selects tells you what was in the braces. A bare hole {x} becomes AppendFormatted<T>(T value). A format specifier {x:N2} becomes AppendFormatted<T>(T value, string? format) with the format as a literal string argument. An alignment {x,10} becomes AppendFormatted<T>(T value, int alignment), and both together {x,10:N2} becomes AppendFormatted<T>(T value, int alignment, string? format). So you can reconstruct the exact brace contents from the overload and its constant arguments — the format text is right there in the call.

There is one behavioral subtlety that the decompiled view makes visible and that bites people who refactor between the forms. DefaultInterpolatedStringHandler formats using the current culture by default, exactly like string.Format. If you need invariant or a specific culture, the interpolated string must be passed to an API that threads an IFormatProvider into the handler's constructor — the compiler then emits a constructor overload taking the provider. If you see a DefaultInterpolatedStringHandler constructed without a provider argument, the result is culture-sensitive; that is a real semantic detail hiding in the lowered IL, not noise.

Custom handlers: when the string is never built

The handler pattern is not limited to building a string. Any method can accept an interpolated string directly by taking a parameter whose type is marked [InterpolatedStringHandler]. The compiler then constructs that handler and appends into it, so the interpolated string is formatted straight into the destination with no intermediate string at all. The clearest example is StringBuilder.Append:

sb.Append($"id={id} name={name}");
Enter fullscreen mode Exit fullscreen mode

does not build a string and append it. It lowers to the construction of a StringBuilder.AppendInterpolatedStringHandler that writes directly into sb:

var handler = new StringBuilder.AppendInterpolatedStringHandler(8, 2, sb); // note: sb passed in
handler.AppendLiteral("id=");
handler.AppendFormatted(id);
handler.AppendLiteral(" name=");
handler.AppendFormatted(name);
sb.Append(ref handler);
Enter fullscreen mode Exit fullscreen mode

The giveaway in the decompiled code is that the handler's constructor takes a third argument — the StringBuilder itself — and the final call is Append(ref handler) rather than Append(string). That extra constructor argument comes from an [InterpolatedStringHandlerArgument("sb")] attribute on the parameter, which tells the compiler to forward the receiver into the handler.

The most striking case is a handler that can decide not to build the string at all. Debug.Assert(condition, $"expensive {Describe()} message") must not pay for the message when the assertion passes. Its handler constructor has a trailing out bool parameter:

var handler = new AssertInterpolatedStringHandler(17, 1, condition, out bool shouldAppend);
if (shouldAppend)
{
    handler.AppendLiteral("expensive ");
    handler.AppendFormatted(Describe());
    handler.AppendLiteral(" message");
}
Debug.Assert(condition, ref handler);
Enter fullscreen mode Exit fullscreen mode

When condition is true the constructor sets shouldAppend = false, the whole if block is skipped, and Describe() is never called. This is the conditional-evaluation behavior people rely on without realizing it, and it is only visible in the lowered form — there is no way to express "skip the interpolation" in the $"..." syntax itself. A decompiler that folds this back to Debug.Assert(condition, $"...") is correct about the result but is hiding the shouldAppend guard; switching to the IL or lowered-C# view in Glass.NET is how you confirm the message really is skipped on the hot path.

Constant interpolation disappears entirely

The last case is the one with nothing to recover. If every part of an interpolated string is a compile-time constant, the whole thing is folded at compile time:

const string Version = "2";
const string Banner = $"Acme v{Version}";   // becomes the constant "Acme v2"
Enter fullscreen mode Exit fullscreen mode

Banner is emitted as the literal string "Acme v2". There is no handler, no Format, no Concat — the metadata holds a constant, and any reference to Banner is just ldstr "Acme v2". A decompiler shows a plain string constant, because that is all that exists. The same is true of a no-hole interpolated string like $"ready", which is just the literal "ready". Interpolation that the compiler could resolve statically leaves no trace of having been interpolation.

What to take from the decompiled view

String interpolation is the cleanest illustration of a theme that runs through this whole series: the surface syntax is a request, and the compiler answers it differently depending on context. The same $"..." can be a String.Concat, a String.Format with a boxed object[], a DefaultInterpolatedStringHandler sequence, a direct append into a StringBuilder, or — when the inputs are constant — nothing but a baked-in literal. Only some of those carry a fingerprint strong enough to rebuild the $"...": the handler dance and the custom-handler forms are recognizable; the concat and format forms are indistinguishable from hand-written calls; the constant form is already gone. A good decompiler folds back what it can prove and shows the lowered calls where it cannot — and the lowered calls are often the point, because that is where you see the boxing, the buffer allocation, the culture dependence, and the shouldAppend guard that the sugar quietly hides. Open the assembly in Glass.NET, switch a method to IL, and watch a one-line $"..." unfold into the calls the runtime actually makes. For the full workflow, see how to decompile a .NET DLL to C#.

Top comments (0)