Decompiling C# records: the generated members that give them away
A record is a class plus a precise set of compiler-generated members — value equality, a clone method, a Deconstruct, a PrintMembers/ToString, init-only setters. A decompiler recognizes that fingerprint and rebuilds the record keyword. Here is exactly what the compiler emits for a record and how the pattern is read back.
C# records look like a language feature, but in the compiled assembly there is no “record” marker — a record is an ordinary class with a very specific set of generated members bolted on. That set is distinctive enough to be a fingerprint, and recognizing it is how a decompiler turns a pile of synthesized methods back into the single word record. Walking through what the compiler actually emits explains both the recognition and the behaviour you get for free when you declare one.
What a positional record expands to
Take the canonical one-liner:
public record Point(int X, int Y);
That compiles to a full class. Decompiled without record recognition, it is roughly:
public class Point : IEquatable<Point>
{
public int X { get; init; } // init-only, from the primary ctor
public int Y { get; init; }
public Point(int X, int Y) { this.X = X; this.Y = Y; }
protected virtual Type EqualityContract => typeof(Point);
public override bool Equals(object obj) => Equals(obj as Point);
public virtual bool Equals(Point other) =>
other is not null && EqualityContract == other.EqualityContract
&& EqualityComparer<int>.Default.Equals(X, other.X)
&& EqualityComparer<int>.Default.Equals(Y, other.Y);
public override int GetHashCode() => /* combine EqualityContract, X, Y */;
public static bool operator ==(Point a, Point b) => /* ... */;
public static bool operator !=(Point a, Point b) => !(a == b);
protected Point(Point original) { X = original.X; Y = original.Y; } // copy ctor
public virtual Point <Clone>$() => new Point(this); // clone
public override string ToString() { /* uses PrintMembers */ }
protected virtual bool PrintMembers(StringBuilder builder) { /* X = .., Y = .. */ }
public void Deconstruct(out int X, out int Y) { X = this.X; Y = this.Y; }
}
One line of source, a dozen generated members. Every one of them is behaviour you would otherwise write by hand, and every one is a clue.
The fingerprint a decompiler keys on
A decompiler does not guess from “this class has an Equals.” It looks for the specific, co-occurring set that only a record produces:
EqualityContract— aprotected virtual Typeproperty returning the type. Nothing but a record emits this, so it is the strongest single signal. (In a sealed or derived record it isprotectedorprivate protectedaccordingly, which also tells the decompiler about the record’s inheritance.)<Clone>$— a clone method with a name that is unspeakable in C# (the$cannot appear in a source identifier). It exists solely to implement thewithexpression. Its presence is unambiguous.- Value equality —
IEquatable<T>.Equals(T), an overriddenEquals(object),GetHashCode, and the==/!=operators, all compiler-generated and all consistent with each other. PrintMembers+ToString— aprotected virtual bool PrintMembers(StringBuilder)paired with aToStringthat calls it. ThePrintMembersname and signature are a record-specific convention.Deconstruct— present when the record is positional (declared with a parameter list). Its out-parameters line up with the primary-constructor parameters, which is how the decompiler knows to writerecord Point(int X, int Y)rather than a record with separately-declared properties.
When the whole set is present and carries the compiler-generated markers, the decompiler collapses it to one line. When only part of it is there — say someone hand-wrote an Equals and a <Clone>$-shaped method is absent — it does not pretend; it shows a class. That honesty matters: the record keyword is a claim about generated semantics, and a decompiler should only make the claim when the evidence is complete.
Init-only properties and the modreq trick
The X and Y properties above are init, not set, and that too lives in the IL in a recognizable form. An init-only setter is an ordinary setter whose signature carries a required modifier (modreq) referencing System.Runtime.CompilerServices.IsExternalInit:
.method public hidebysig specialname instance void
set_X(int32 'value') ...
// the return type is: void modreq(IsExternalInit)
That modreq is the whole mechanism: the compiler refuses to call the setter outside an object initializer because callers must understand the modifier, and older compilers that don’t will reject it. A decompiler reads the modreq and renders init;. It is also a corroborating signal for record recognition — positional-record parameters become init-only properties, so a cluster of init setters alongside the EqualityContract/Deconstruct pattern reinforces that this is a positional record rather than a class that happens to have value equality.
with, record struct, and inheritance
Three further details show up in the decompiled shape. The with expression compiles to a call to <Clone>$ followed by init-only property assignments on the clone — so when you see <Clone>$ invoked and then a few set_/init calls, that is a with in the original. record struct follows the same playbook minus the clone method and the inheritance machinery (structs don’t inherit), so the fingerprint is EqualityContract-free but still has the generated value-equality and PrintMembers set, and a decompiler distinguishes it from a plain struct by exactly those members. Record inheritance leaves its own trace: a derived record’s Equals compares EqualityContract, its PrintMembers calls base.PrintMembers, and the clone method is covariant — all of which let the decompiler reconstruct the : BaseRecord relationship rather than flattening it.
Why read it back at all
Almost always you want the record keyword, not the dozen expanded members — it is what you wrote and what you mean, and Glass.NET reconstructs it by default. But the expansion underneath explains behaviour that surprises people. Value equality is structural over the declared members, which is why two records with equal fields are Equals even as distinct objects — and why adding a mutable collection property gives you equality that ignores the collection’s contents, visible immediately once you see the generated Equals only compares the member references. The with expression is a shallow clone, visible as the <Clone>$ call that copies references, not deep structure. And EqualityContract being part of equality is exactly why a base record and a derived record are never equal even with identical fields — the contract types differ. Reading the generated members is how those behaviours stop being folklore and become something you can see. A record is syntax; its semantics are a specific set of methods, and once you can recognize the set, the decompiled view and the source view tell the same story.
Try Nebula.NET
Harden your .NET code in minutes — start with the free edition.