Skip to content
← All posts
· Delta1 Labs Glass.NETDecompilerGuide

Decompiling string interpolation: reading the handler back to $"..."

Modern C# does not compile $"Hello {name}" into string.Format anymore — it lowers to a DefaultInterpolatedStringHandler with AppendLiteral and AppendFormatted calls. Here is exactly what that looks like in IL, how Glass.NET pattern-matches the sequence back into an interpolated string with its format and alignment intact, and the one case where it honestly cannot.

Here is a small puzzle. You decompile a modern .NET assembly, land on a method that clearly built a message, and instead of the $"..." you wrote you see a local of type DefaultInterpolatedStringHandler, a constructor taking two integers, and a run of AppendLiteral / AppendFormatted calls. Nothing is wrong — this is your interpolated string. Since C# 10 and .NET 6 the compiler stopped lowering $"..." to string.Format and started building it through a handler, for good performance reasons. A decompiler that didn’t keep up would leave you reading that mechanical call sequence by hand.

This post is a working tour of that lowering and how Glass.NET reads it back. We’ll look at the real IL the compiler emits, how the pieces map one-to-one onto an interpolated string, how format specifiers and alignment survive the round trip, and the one honest case — custom handlers — where the source-level shape genuinely differs from $"..." and a faithful decompiler shows you that instead of inventing it.

What the compiler emits

Take an ordinary interpolation with two holes:

string Describe(string name, int count) =>
    $"Hello {name}, you have {count} messages";

On .NET 6+ that does not become string.Format. The compiler allocates a DefaultInterpolatedStringHandler on the stack, tells it up front how much literal text and how many holes to expect, feeds it the literals and values in order, and asks for the finished string. In IL:

ldloca.s   handler
ldc.i4.s   21                        // total length of the literal text
ldc.i4.2                             // number of interpolation holes
call       instance void [System.Runtime]System.Runtime.CompilerServices.DefaultInterpolatedStringHandler::.ctor(int32, int32)
ldloca.s   handler
ldstr      "Hello "
call       instance void DefaultInterpolatedStringHandler::AppendLiteral(string)
ldloca.s   handler
ldarg.1                              // name
call       instance void DefaultInterpolatedStringHandler::AppendFormatted<string>(!!0)
ldloca.s   handler
ldstr      ", you have "
call       instance void DefaultInterpolatedStringHandler::AppendLiteral(string)
ldloca.s   handler
ldarg.2                              // count
call       instance void DefaultInterpolatedStringHandler::AppendFormatted<int32>(!!0)
ldloca.s   handler
ldstr      " messages"
call       instance void DefaultInterpolatedStringHandler::AppendLiteral(string)
ldloca.s   handler
call       instance string DefaultInterpolatedStringHandler::ToStringAndClear()
ret

The structure is rigid and that’s the whole point — it is exactly what makes it recoverable. There is a constructor carrying two hint integers, then a strictly alternating run of AppendLiteral (a constant string) and AppendFormatted<T> (a value), and finally one ToStringAndClear that produces the result. The two constructor integers are a sizing hint — 21 is the combined length of "Hello ", ", you have " and " messages", and 2 is the hole count — so the handler can pre-size its buffer. They are not part of the text; a decompiler reads them as corroboration, not content.

The reconstruction, piece by piece

Glass.NET matches that shape and walks it in emission order, turning each call back into a fragment of the original literal:

AppendLiteral("Hello ")AppendFormatted(name)AppendLiteral(", you have ")AppendFormatted(count)AppendLiteral(" messages")ToStringAndClear()reconstructed source$"Hello {name}, you have {count} messages"literal fragments → text · formatted holes → {expr}

Each AppendLiteral("Hello ") contributes its constant straight into the literal text. Each AppendFormatted(expr) becomes a {expr} hole, with expr decompiled from whatever was pushed as the argument — a local, a field, a whole subexpression. The closing ToStringAndClear() tells the matcher the string is complete. Reassembled in order, you get back exactly $"Hello {name}, you have {count} messages" — not a transcription of handler calls.

Format and alignment survive the trip

Interpolation holes carry two optional modifiers: an alignment ({value,-10}) and a format specifier ({value:C2}). These don’t vanish in the lowering — they select a different AppendFormatted overload. Given:

string Row(string label, decimal price) =>
    $"{label,-12}{price,8:C2}";

the compiler calls the overloads that take the extra arguments: AppendFormatted<string>(value, int alignment) for the left-aligned label, and AppendFormatted<decimal>(value, int alignment, string format) for the right-aligned, currency-formatted price:

ldstr      ""                        // no leading literal; holes are adjacent
...
ldarg.1
ldc.i4.s   -12
call       instance void DefaultInterpolatedStringHandler::AppendFormatted<string>(!!0, int32)
...
ldarg.2
ldc.i4.8
ldstr      "C2"
call       instance void DefaultInterpolatedStringHandler::AppendFormatted<decimal>(!!0, int32, string)

Glass maps the trailing int back to the alignment after the comma and the trailing string back to the format after the colon, so it reconstructs $"{label,-12}{price,8:C2}" with both modifiers in place. The overload is the metadata — the decompiler doesn’t have to guess which argument meant what, because the signature says so.

When it isn’t a handler at all

Two cases legitimately produce something other than the handler shape, and recognising them is as much a part of correct decompilation as matching the handler:

All-constant interpolation folds to a literal. If every hole is a compile-time constant, there is nothing to format at runtime — the compiler computes the final string and emits a single ldstr. So $"v{1}.{0}" becomes the constant "v1.0" in IL, and the decompiler shows "v1.0", correctly, because the interpolation no longer exists in the compiled code. The $ was a source convenience the compiler already spent.

Older targets lower to string.Format. The handler type arrived with .NET 6. Target netstandard2.0 or .NET Framework and the same $"Hello {name}" lowers to string.Format("Hello {0}", name) instead. Glass reconstructs $"..." from that classic pattern too — matching the format string’s {0}, {1} placeholders against the argument array — so you get interpolation back regardless of which lowering your target used. Seeing a handler versus a Format call is really a signal of the assembly’s target framework.

The honest limit: custom handlers

The one case where a faithful decompiler will not show you $"..." is a custom interpolated-string handler — a type marked [InterpolatedStringHandler], used by things like high-performance logging and Debug.Assert. There, the whole point is that the interpolation has different semantics: the handler can decide not to evaluate the holes at all. When you write

logger.LogDebug($"expensive {ComputeDiagnostics()} detail");

against an overload that takes a logging interpolation handler, the compiler emits a construction of that handler with an out bool shouldAppend enabled flag, and guards each AppendFormatted so ComputeDiagnostics() runs only if the debug level is actually enabled. That conditional evaluation is not expressible as a plain $"..." — rendering it that way would be a lie about when your code runs. A good decompiler shows the handler construction and the guarded appends, which is the truth of what executes. If you want the readable intent back, that’s a spot to add a comment during project export; the mechanical form is correct, just lower-level than the sugar you typed.

Why this matters when you read a binary

String interpolation is everywhere — log messages, exceptions, UI strings, SQL (please don’t) — and those are exactly the strings you grep for when you’re trying to understand an unfamiliar assembly, trace where a value is formatted, or audit a dependency. A decompiler that dumps raw AppendLiteral sequences makes every one of those a small decoding chore; one that reconstructs $"..." lets you read the code as the author wrote it. Glass.NET does the latter because it rides ICSharpCode.Decompiler, the same engine as ILSpy, which tracks the modern lowerings closely. Point it at any .NET DLL, navigate to a method, and the interpolated strings read like source — see Getting started and the navigation guide to try it on your own binaries.

Try Nebula.NET

Harden your .NET code in minutes — start with the free edition.