DEV Community

Zero Heartbeat
Zero Heartbeat

Posted on Originally published at delta1labs.com

Decompiling C# records: the generated members that give them away

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);
Enter fullscreen mode Exit fullscreen mode

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; }
}
Enter fullscreen mode Exit fullscreen mode

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 — a protected virtual Type property returning the type. Nothing but a record emits this, so it is the strongest single signal. (In a sealed or derived record it is protected or private protected accordingly, 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 the with expression. Its presence is unambiguous.
  • Value equality — IEquatable<T>.Equals(T), an overridden Equals(object), GetHashCode, and the ==/!= operators, all compiler-generated and all consistent with each other.
  • PrintMembers + ToString — a protected virtual bool PrintMembers(StringBuilder) paired with a ToString that calls it. The PrintMembers name 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 write record 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)
Enter fullscreen mode Exit fullscreen mode

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.

Top comments (0)