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


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](https://delta1labs.com/products/glass) 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:

```csharp
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:

```text
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:

<figure class="my-8"><svg viewBox="0 0 660 232" role="img" aria-label="Each IL call maps to one fragment of the interpolated string: AppendLiteral calls become literal text, AppendFormatted calls become holes, and ToStringAndClear closes the string, reassembling the original interpolated string" style="width:100%;height:auto"><defs><style>.il{fill:#eef4ff;stroke:#8eb4ff}.ho{fill:#fef9c3;stroke:#fde047}.out{fill:#bbf7d0;stroke:#4ade80}.t{font:600 12px system-ui;fill:#1547e0}.m{font:11px ui-monospace,monospace;fill:#334155}.x{font:11px system-ui;fill:#475569}.ar{stroke:#94a3b8;stroke-width:1.5;fill:none}</style><marker id="hg" markerWidth="7" markerHeight="7" refX="5" refY="3" orient="auto"><path d="M0 0l5 3-5 3z" fill="#94a3b8"/></marker></defs><rect class="il" x="14" y="12" width="250" height="24" rx="4"/><text class="m" x="22" y="29">AppendLiteral("Hello ")</text><rect class="ho" x="14" y="44" width="250" height="24" rx="4"/><text class="m" x="22" y="61">AppendFormatted(name)</text><rect class="il" x="14" y="76" width="250" height="24" rx="4"/><text class="m" x="22" y="93">AppendLiteral(", you have ")</text><rect class="ho" x="14" y="108" width="250" height="24" rx="4"/><text class="m" x="22" y="125">AppendFormatted(count)</text><rect class="il" x="14" y="140" width="250" height="24" rx="4"/><text class="m" x="22" y="157">AppendLiteral(" messages")</text><rect class="out" x="14" y="180" width="250" height="24" rx="4"/><text class="m" x="22" y="197">ToStringAndClear()</text><path class="ar" d="M264 24 h60" marker-end="url(#hg)"/><path class="ar" d="M264 56 h60" marker-end="url(#hg)"/><path class="ar" d="M264 88 h60" marker-end="url(#hg)"/><path class="ar" d="M264 120 h60" marker-end="url(#hg)"/><path class="ar" d="M264 152 h60" marker-end="url(#hg)"/><rect class="out" x="330" y="86" width="316" height="60" rx="6"/><text class="t" x="344" y="110">reconstructed source</text><text class="m" x="344" y="132">$"Hello {name}, you have {count} messages"</text><text class="x" x="344" y="172">literal fragments → text · formatted holes → {expr}</text></svg></figure>

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:

```csharp
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:

```text
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

```csharp
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](https://delta1labs.com/blog/glass-export-assembly-to-buildable-project); 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](https://delta1labs.com/products/glass), 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](https://delta1labs.com/docs/glass/getting-started/) and the [navigation guide](https://delta1labs.com/docs/glass/navigation/) to try it on your own binaries.

