DEV Community

Zero Heartbeat
Zero Heartbeat

Posted on Originally published at delta1labs.com

Decompiling init-only setters and required members: the modreq and attributes that make them

init and required are two of the shortest keywords in modern C#, and two of the easiest for a decompiler to throw away without noticing.

They are both compile-time-only features. The runtime does not enforce either one — a reflection-based setter will happily reassign an init property, and nothing stops you constructing an object that skips a required member if you go around the compiler. Both features exist entirely so the compiler can hold callers to a contract: set this once, during construction; you must set this before you have a valid object. That contract is encoded in metadata the compiler reads and everyone else ignores, which means a decompiler has to go read the same metadata or it will hand you source whose contract is quietly weaker than the original.

The signals are small. init is one custom modifier on a setter. required is three attributes plus, on the satisfying constructor, a fourth. Miss them and the code still compiles — that is exactly what makes it dangerous.

init is a modreq on the setter

Take the smallest possible example and look at what the compiler emits:

public class Person
{
    public string Name { get; init; }
}
Enter fullscreen mode Exit fullscreen mode

The property compiles to a getter and a setter like any other, but the setter's signature is not quite ordinary. In IL:

.property instance string Name()
{
    .get instance string Person::get_Name()
    .set instance void modreq([System.Runtime]System.Runtime.CompilerServices.IsExternalInit) Person::set_Name(string)
}

.method public hidebysig specialname instance void
    set_Name(string 'value') cil managed
{
    // ... ordinary field store ...
}
Enter fullscreen mode Exit fullscreen mode

The whole difference between set and init is the modreq([System.Runtime]System.Runtime.CompilerServices.IsExternalInit) on the return type of set_Name. A custom modifier is part of the signature — the CLR treats a signature with a modreq as distinct from one without — and the compiler uses this particular one as a sentinel. When it resolves a call to a setter and sees IsExternalInit in the signature, it permits the call only from an initialization context: the declaring type's constructor, an object initializer, or a with expression. Everywhere else it is a compile error. The runtime enforces nothing; IsExternalInit is just a type the compiler agreed to treat as a flag.

That makes the decompiler's job precise. Read the setter's signature; if the return carries modreq(IsExternalInit), render init; otherwise render set. A decompiler that walks only the method body and the property's get/set pair, without inspecting the custom modifiers on the setter signature, loses the distinction and emits set. The output compiles — that is the trap — but the regenerated Person now lets any caller write p.Name = "..." long after construction, a mutation the author's init was specifically there to forbid.

Glass.NET reads the modreq off the setter signature and reconstructs the keyword:

// Decompiled by Glass.NET — the init is recovered from modreq(IsExternalInit)
public class Person
{
    public string Name { get; init; }
}
Enter fullscreen mode Exit fullscreen mode

required is three attributes that must travel together

Now add the other keyword:

public class Person
{
    public required string Name { get; init; }
    public required string Email { get; init; }
}
Enter fullscreen mode Exit fullscreen mode

There is no new IL for the property bodies — required generates no code at all. It generates attributes, and three distinct ones, because three different things need marking. Decompiled from metadata, the type carries:

.class public auto ansi beforefieldinit Person
{
    .custom instance void [System.Runtime]System.Runtime.CompilerServices.RequiredMemberAttribute::.ctor() = ( 01 00 00 00 )
    .custom instance void [System.Runtime]System.Runtime.CompilerServices.CompilerFeatureRequiredAttribute::.ctor(string) = ( 01 00 0F 52 65 71 75 69 72 65 64 4D 65 6D 62 65 72 73 00 00 )
    //                                                                                              "RequiredMembers"
    // and on each required property:
    .property instance string Name()
    {
        .custom instance void [System.Runtime]System.Runtime.CompilerServices.RequiredMemberAttribute::.ctor() = ( 01 00 00 00 )
        // ...
    }
}
Enter fullscreen mode Exit fullscreen mode

The attribute blob on CompilerFeatureRequired is worth decoding, because it is the fail-closed guard. 01 00 is the prolog; 0F is the length-prefix (15) of the UTF-8 string that follows, 52 65 71 75 69 72 65 64 4D 65 6D 62 65 72 73 = "RequiredMembers"; 00 00 is the (empty) named-argument count. The three attributes do three jobs:

  • RequiredMemberAttribute on each member marks which members are required. These are the ones a consuming compiler forces callers to assign.
  • RequiredMemberAttribute on the type is a quick marker that the type has required members at all, so a consumer need not scan every member to find out.
  • CompilerFeatureRequiredAttribute("RequiredMembers") on the type is the guard. It tells a compiler that does not understand required members to refuse the type rather than ignore the requirement — the language-feature equivalent of failing closed. Without it, an old compiler would happily let callers skip members the author demanded.

A decompiler that filters these out as "compiler-generated noise" — a reasonable-sounding default, since you do not want [Nullable] or [IsReadOnly] cluttering your output — strips the entire required contract. The regenerated type has ordinary optional properties, and every caller that the original forced to write new Person { Name = ..., Email = ... } is now free to write new Person { }. The contract did not weaken loudly; it just disappeared.

Glass.NET recognises the RequiredMemberAttribute/CompilerFeatureRequired pairing as the encoding of required, consumes the attributes, and re-emits the keyword on exactly the members that carried the member-level attribute:

public class Person
{
    public required string Name { get; init; }
    public required string Email { get; init; }
}
Enter fullscreen mode Exit fullscreen mode

[SetsRequiredMembers] is load-bearing on constructors

There is a fourth attribute, and it lives on constructors. Consider a type that offers a constructor which assigns everything required:

public class Person
{
    public required string Name { get; init; }
    public required string Email { get; init; }

    [SetsRequiredMembers]
    public Person(string name, string email)
    {
        Name = name;
        Email = email;
    }
}
Enter fullscreen mode Exit fullscreen mode

[SetsRequiredMembers] tells the compiler that this constructor already satisfies every required member, so callers using it are not forced to re-set those members in an object initializer. Without it, new Person("a", "b") would still demand { Name = ..., Email = ... }, which would be absurd. The compiler emits it as System.Diagnostics.CodeAnalysis.SetsRequiredMembersAttribute on the constructor, and it is emitted automatically behind a record's generated copy constructor so that with expressions work.

This one is load-bearing in the strongest sense: drop it and the regenerated code does not even compile the same way. A decompiler that omits [SetsRequiredMembers] produces a constructor that the compiler then judges does not satisfy the required members, so any object-initializer caller is suddenly forced to set properties the original design let them omit — or, if the constructor is the only path, the type becomes awkward to use in exactly the way the attribute existed to prevent. Glass.NET surfaces it on the constructor verbatim, because the type does not round-trip without it.

Reading both signals, independently

The reason both features need care is that they are orthogonal, and their four combinations are four different contracts:

public string A { get; set; }           // mutable, optional
public string B { get; init; }          // set once, optional
public required string C { get; set; }  // must be set, then mutable
public required string D { get; init; } // must be set, construction only
Enter fullscreen mode Exit fullscreen mode

In metadata that is the independent combination of two signals: the IsExternalInit modreq on the setter (present → init, absent → set) and the RequiredMemberAttribute on the member (present → required, absent → not). A decompiler must read them separately and combine them, because each one collapsed on its own produces a different wrong answer — lose the modreq and D becomes reassignable; lose the attribute and D becomes optional; lose both and it is a plain mutable property with none of the guarantees the author wrote.

That is the throughline for this whole family of C# features. The keyword you typed left almost no trace in the executable code — no new IL in the bodies, just a modifier on a signature and a handful of attributes on declarations. The compiler put the contract into metadata and trusted the next compiler to read it. A decompiler that wants to give you back the source the author actually wrote has to be that next reader: inspect the custom modifiers, keep the attributes that encode language features rather than filtering them as noise, and reconstruct init, required, and [SetsRequiredMembers] precisely, so the type you get back enforces exactly what the original did.

Top comments (0)