Tuples are one of the places where C#'s syntax and the CLR's type system quietly disagree, and the disagreement is visible the moment you decompile. You wrote (int count, string name); the runtime has never heard of count or name. The tuple is System.ValueTuple<int, string>, and its two fields are Item1 and Item2 — fixed, generic, nameless. So where did your names go? They are real, they survive compilation, and they live somewhere surprising: in an attribute bolted onto the signature, not in the type at all.
That split is why two decompilers can disagree about the same method — one shows (int count, string name) and the other ValueTuple<int, string> with Item1/Item2 — and why tuple names come back cleanly on a return type but evaporate on a local variable. This is a working tour of the encoding: what [TupleElementNames] holds, how nested tuples flatten, where names are not stored at all, and how Glass.NET reads it back to the tuple you typed.
The source
A method whose signature carries names on both its parameter and its return, plus a nested tuple to show the flattening, plus one deliberately-unnamed element:
public static class Stats
{
public static (int count, string name) Summarize((int id, string label) input)
{
var scratch = (input.id, input.label.Trim()); // a LOCAL tuple
return (scratch.Item1, scratch.Item2);
}
public static (int id, (string first, string last) name) Split(string full) =>
(1, (full.Split(' ')[0], full.Split(' ')[1]));
public static (int count, string) Partial() => (0, "unnamed"); // mixed
}
Where the names actually are
Compile Summarize and look at the metadata. The return type is System.ValueTuple\2— yourcountandnameare nowhere in it. Instead the compiler attaches aTupleElementNamesAttribute` to the return, carrying a string array parallel to the tuple's positions:
csharp2
.method public hidebysig static valuetype [System.Runtime]System.ValueTuple
Summarize(valuetype [System.Runtime]System.ValueTuple`2 input) cil managed
{
// on the RETURN:
.param [0]
.custom instance void [System.Runtime]System.Runtime.CompilerServices.TupleElementNamesAttribute::.ctor(string[])
= { string2 }
// on the PARAMETER 'input':
.param [1]
.custom instance void ...TupleElementNamesAttribute::.ctor(string[])
= { string[2]('id', 'label') }
...
}
`
So the name mapping is: the attribute's array index is the tuple position. ["count", "name"] on the return means Item1 → count, Item2 → name. The attribute is emitted wherever the names form a visible contract — returns, parameters, fields, properties — so a caller in another assembly can bind to them. The type stays ValueTuple; only the attribute carries your intent.
Nested tuples flatten with nulls
Split returns (int id, (string first, string last) name) — a tuple whose second element is itself a tuple. There is no nested attribute; the names are flattened into one array by a pre-order walk, with a null at each position that is a composite (non-leaf) node:
llvm
.custom instance void ...TupleElementNamesAttribute::.ctor(string[])
= { string[4]('id', nullref, 'first', 'last') }
Read it as a tree traversal: id is the first leaf, null marks the nested tuple container itself (it has a name — name — but wait: the outer name of the nested group is also carried). In practice the array is ["id", "name", "first", "last"] when the nested group is itself named, and the null placeholder appears for an unnamed composite. The decompiler rebuilds the shape by walking the ValueTuple type tree and consuming names positionally, so it knows first/last belong to the inner tuple and id to the outer. The key invariant: the array length equals the number of positions across the flattened shape, and the reader uses the type structure, not the array alone, to re-nest.
Mixed and unnamed elements use null
Partial returns (int count, string) — one named, one not. Unnamed positions are stored as null:
llvm
.custom instance void ...TupleElementNamesAttribute::.ctor(string[])
= { string[2]('count', nullref) }
So ["count", null] reconstructs as (int count, string) — Item1 gets its name, Item2 falls back to the positional Item2, which is exactly what you access it by in source. This is why a decompiler must treat null as "no name here," not as an empty string or an error.
What a naive decompiler shows
A decompiler that reads the type but not the attribute is correct about the shape and blind to the names. Every tuple becomes ValueTuple<…> and every access is Item1/Item2:
`csharp
// Naive output: names dropped, contract obscured.
public static ValueTuple Summarize(ValueTuple input)
{
ValueTuple scratch = new ValueTuple(input.Item1, input.Item2.Trim());
return new ValueTuple(scratch.Item1, scratch.Item2);
}
public static ValueTuple> Split(string full) => …;
`
It compiles to the same thing, but it has thrown away the one piece of information that made the signature self-documenting — and a caller reading this can no longer tell that Item1 is a count and Item2 a name.
What Glass reconstructs
Glass is built on ICSharpCode.Decompiler, the ILSpy engine, which reads TupleElementNamesAttribute off each signature and maps the names back positionally — restoring nested shape from the flattened array, and leaving unnamed slots as positional:
`csharp
// Glass output: the signatures you wrote.
public static (int count, string name) Summarize((int id, string label) input)
{
(int, string) scratch = (input.id, input.label.Trim());
return (scratch.Item1, scratch.Item2);
}
public static (int id, (string first, string last) name) Split(string full) => …;
public static (int count, string) Partial() => (0, "unnamed");
`
Notice what came back and what did not. The signatures — return and parameter of Summarize, the nested return of Split, the mixed return of Partial — are exactly as written, because the attribute preserved them. But scratch, the local, is reconstructed as a plain (int, string) accessed through Item1/Item2. That is not a Glass limitation; it is the truth of the metadata. The compiler emits no TupleElementNamesAttribute for a local variable, because a local's element names are not a contract anyone outside the method body can see — there is nothing in the assembly that ever recorded that you called them id and label inside the method. Glass shows exactly what survived and invents nothing.
When to drop to the IL view
The attribute encoding is one of the clearest cases where seeing the raw metadata explains the decompiled C#:
-
Why did a name come back here but not there? Switch to IL and look for the
.paramcarryingTupleElementNamesAttribute. Present on the return → names restored; absent (a local) →Item1/Item2. The presence or absence of the attribute is the explanation. -
Reading a nested shape. The flattened array with its
nullplaceholders is only visible in IL. When a reconstructed nested tuple looks surprising, the array in the attribute is the ground truth for which name belongs to which position. -
Cross-assembly binding. Because the names live on the signature, a consumer in another assembly binds to
.count/.namepurely from this attribute. Confirming it is emitted is how you verify a library actually exposes the named tuple API you intended.
Read the C# to get the named signatures back; drop to the IL to see that the names were never in the type — they were an attribute the whole time, and Item1/Item2 on a local is the metadata telling you the truth.
Top comments (0)