Skip to content
← All posts
· Delta1 Labs DecompilationGlass

Decompiling generic constraints and variance: reconstructing where-clauses and out/in from metadata

Generic type-parameter constraints and variance are, like struct layout, features a decompiler cannot recover from code shapes — they live entirely in metadata. Each generic parameter carries a variance direction and special-constraint flags in the GenericParam table, and the GenericParamConstraint table lists the base type and interfaces a parameter is bound to. Covariance (out) and contravariance (in) are flag bits; the class / struct / new() constraints are flag bits too; but notnull and unmanaged are encoded indirectly, through a modreq and an attribute rather than a raw flag. Drop the variance and an interface stops being assignable the way it was; re-emit the constraints in the wrong order and the output does not compile at all. This post shows where the data lives, how Glass.NET reads the GenericParam and GenericParamConstraint tables, how it sorts the constraints into the exact order C# requires, and a worked covariant-interface plus multi-constraint-method example.

Most C# features a decompiler recovers are compiler sugar: the compiler lowered a foreach into a try/finally around an enumerator, or an iterator into a state machine, and the decompiler recognises the shape of the emitted code and folds it back. Generic constraints and variance are not like that. There is no code to recognise, because a constraint is not executable. where T : class, IComparable<T>, new() and the out in IProducer<out T> compile to metadata — rows in a couple of side tables and a handful of flag bits — and nothing in any method body mentions them. A decompiler that only reconstructs IL into statements will emit a generic type that is missing its variance, missing its constraints, or carrying them in an order that does not compile.

That matters because constraints and variance are part of a type’s public contract. The constraints decide what a caller is allowed to substitute for T; the variance decides which assignments between constructed types are legal. Get them wrong and the regenerated type either rejects code that used to work or, worse, silently loosens a guarantee callers relied on. This post walks exactly where the data lives in metadata, how Glass reads it back, and how it sorts the constraints into the one order C# will accept.

Variance and constraints in source

Here are the three shapes this post reconstructs. First a covariant interface — T appears only in output position, so it can be marked out:

public interface IProducer<out T>
{
    T Produce();
}

Covariance is what lets an IProducer<string> be handed to code expecting an IProducer<object>: every string the producer yields is an object, so the assignment is safe and the compiler allows it. The mirror image is contravariance — T appears only in input position, so it can be marked in:

public interface ISink<in T>
{
    void Accept(T value);
}

An ISink<object> can stand in for an ISink<string>, because anything that accepts any object certainly accepts a string. Finally a method with a full set of constraints:

public static T CreateAndRank<T>(IEnumerable<T> items)
    where T : class, IComparable<T>, new()
{
    var seed = new T();
    foreach (var item in items)
        if (item.CompareTo(seed) < 0)
            seed = item;
    return seed;
}

T must be a reference type (class), must be comparable to itself (IComparable<T>), and must have a public parameterless constructor (new()). All three facts are recorded in metadata, in a specific order-free form that the decompiler has to turn back into a legal, ordered where clause.

Where the data actually lives

Two metadata tables and one bitmask carry everything.

The GenericParam table has one row per type parameter, for both types and methods. Each row stores the parameter’s zero-based position, its name, its owner, and a Flags column with two independent bit groups:

  • The VarianceMask (low two bits): 0x0 non-variant, 0x1 covariant (out), 0x2 contravariant (in). Only ever non-zero on interface and delegate parameters.
  • The SpecialConstraintMask: 0x04 reference-type constraint (class), 0x08 value-type / non-nullable constraint (struct), 0x10 default-constructor constraint (new()).

The GenericParamConstraint table has one row per type constraint, each pairing a GenericParam row with a TypeDefOrRef — the base class or interface the parameter is bound to. where T : SomeBase, IComparable<T> produces two rows for the same parameter: one pointing at SomeBase, one at IComparable<T>.

Rendered the way ILDasm shows it, the covariant interface puts +T right in the class header:

.class interface public abstract auto ansi IProducer`1<+T>
{
    .method public hidebysig newslot abstract virtual
        instance !T Produce() cil managed
    {
    }
}

The +T is the VarianceMask covariant bit; a contravariant parameter renders as -T, and a non-variant one as plain T. The 1 is arity. For the constrained method, ILDasm prints the base-type constraint in parentheses before the parameter name and the special constraints as keywords on the parameter:

.method public hidebysig static !!T CreateAndRank<class .ctor
    ([mscorlib]System.IComparable`1<!!T>) T>
    (class [mscorlib]System.Collections.Generic.IEnumerable`1<!!T> items)
    cil managed
{
}

Read that header carefully: class and .ctor are the SpecialConstraintMask bits (class + new()), the parenthesised ([mscorlib]System.IComparable1<!!T>)is the singleGenericParamConstraintrow, andT` is the parameter name. None of this appears in any instruction — it is pure declarative metadata attached to the method’s signature.

The notnull and unmanaged subtlety

The three classic special constraints each own a SpecialConstraintMask bit. The two modern ones do not, because the bitmask was frozen before they existed, and they are encoded indirectly:

  • notnull is carried by a System.Runtime.CompilerServices attribute on the GenericParam, with no special-constraint flag set. A reader that only consults SpecialConstraintMask sees nothing and emits an unconstrained parameter, dropping the constraint entirely.
  • unmanaged is encoded as the struct flag (0x08) plus a GenericParamConstraint row whose type carries a required modifier — a modreq of System.Runtime.CompilerServices.IsUnmanagedAttribute — on a System.ValueType constraint. A reader that sees only the struct bit emits where T : struct, which is a strictly weaker constraint than unmanaged and silently lets managed value types through.

In IL the unmanaged case looks like a struct-flagged parameter with a modreq’d ValueType constraint:

.method ... static void Blit<valuetype .ctor
    (class [System.Runtime]System.ValueType modreq(
        [System.Runtime]System.Runtime.CompilerServices.IsUnmanagedAttribute)) T>
    (...) cil managed

The presence of the modreq(...IsUnmanagedAttribute) is the entire difference between unmanaged and struct. Glass checks for it explicitly, and for the notnull attribute, so neither modern constraint is lost or widened.

What a naive decompiler produces

A decompiler built around method bodies has nothing to anchor on here, because none of this is in a method body. Two failure modes dominate, and both are quiet.

Dropped variance. The interface comes back invariant:

// WRONG — the out is gone
public interface IProducer<T>
{
    T Produce();
}

This compiles. Its single member is identical. But it is no longer covariant, so every IProducer<string>-to-IProducer<object> assignment that callers wrote against the original now fails to compile against the regenerated interface. Nothing in IProducer itself looks wrong; the breakage shows up only at the assignment sites, frequently in other assemblies.

Wrong constraint order. A decompiler that walks the metadata and appends keywords as it encounters them emits something like:

// WRONG — does not compile
public static T CreateAndRank<T>(IEnumerable<T> items)
    where T : IComparable<T>, class, new(), struct
{ ... }

C# rejects this outright. The class constraint must come first, new() must come last, class and struct are mutually exclusive, and interface constraints sit in the middle. The metadata preserves none of this ordering — the GenericParamConstraint rows are in an arbitrary order and the flag bits have no order at all — so the decompiler has to impose the correct order rather than echo what it read.

The figure below shows the two metadata sources feeding Glass’s constraint sorter, which buckets each constraint by category and emits the buckets in the fixed C# order:

GenericParam.Flags (parameter T) SpecialConstraint: class | .ctor VarianceMask: 0x0 (non-variant) GenericParamConstraint rows T -> IComparable`1<!T> Glass constraint sorter Fixed C# order the sorter emits class IComparable<T> new() class/struct first, then base type, then interfaces, then new() last. where T : class, IComparable<T>, new() Order is imposed by language rule, not read from the table — the rows carry no order.

How Glass reconstructs it

Glass treats generic parameters as a first-class metadata concern, separate from method decompilation. For each parameter — type or method — it runs an explicit pipeline:

  1. Read the VarianceMask from GenericParam.Flags. Covariant becomes out, contravariant becomes in, and the keyword is placed in the declaration (IProducer<out T>), not the where clause. Variance is only read for interface and delegate parameters, where it is legal.
  2. Read the SpecialConstraintMask for the class, struct, and new() bits — but before trusting a bare struct bit, check whether the ValueType constraint carries the IsUnmanagedAttribute modreq, in which case the constraint is unmanaged, not struct. Check the GenericParam attributes for the notnull marker too.
  3. Read every GenericParamConstraint row for the parameter and resolve each TypeDefOrRef to a base class or interface. A class type goes in the base-type bucket; interfaces go in the interface bucket.
  4. Sort into the fixed C# order and emit: the class / struct / unmanaged / notnull constraint first, then the base type, then the interfaces, then new() last. Buckets that are empty are skipped, and a parameter with no constraints at all gets no where clause.

The result is the source you started with, contract-for-contract equivalent:

public static T CreateAndRank<T>(IEnumerable<T> items)
    where T : class, IComparable<T>, new()
{
    var seed = new T();
    foreach (var item in items)
        if (item.CompareTo(seed) < 0)
            seed = item;
    return seed;
}

Because the class and new() keywords came from the SpecialConstraintMask and IComparable<T> came from the GenericParamConstraint row, and because the sorter placed them in the order C# demands, the method recompiles with exactly the constraints the original declared — no stronger, no weaker, and in an order the compiler accepts.

A covariant interface, round-tripped

The interface reconstruction is shorter but shows the variance path end to end. Glass reads the +T covariant bit from the single GenericParam row and re-emits the out:

public interface IProducer<out T>
{
    T Produce();
}

The out is back in the declaration, so IProducer<string> is once again assignable to IProducer<object>. Had Glass read only the member signatures and skipped the VarianceMask, the interface would have come back invariant and every variant assignment downstream would have broken — the kind of loss that is invisible in the type itself and only surfaces at the call sites.

Running it

Point Glass at the assembly and ask for the one type. Note the backtick-arity in the type name:

glass decompile MyApp.dll --type MyApp.Abstractions.IProducer`1

The output is the out-annotated interface above. To decompile the constrained method’s declaring type and confirm the constraints round-trip:

glass decompile MyApp.dll --type MyApp.Ranking.Ranker --members

To sweep a whole namespace and verify every generic parameter’s variance and constraints survive reconstruction:

glass decompile MyApp.dll --namespace MyApp.Abstractions --constraint-check

--constraint-check re-reads the GenericParam and GenericParamConstraint tables after reconstruction and verifies that the emitted variance keywords, special constraints, and where-clause type bindings match the metadata exactly — so you get a guarantee, not a hope, that the regenerated signatures carry the same contract as the original. Glass is a free, open decompiler; this is standard output, nothing gated.

Why this is worth getting exactly right

Variance and constraints are the part of a generic type that the type’s users see. A dropped out does not change IProducer’s members, but it quietly revokes an assignability promise that other code was built on. A mis-ordered where clause does not change T’s meaning, but it stops the file compiling. And a struct emitted where the metadata said unmanaged loosens a guarantee without a single warning, because the looser constraint still accepts everything the stricter one did and more.

So the rule Glass follows is the same one that governs struct layout: this is metadata, metadata is ground truth, and the reconstruction reads it rather than guessing. Read the VarianceMask, read the SpecialConstraintMask (and the modreq and attribute that hide behind a bare struct bit), read every GenericParamConstraint row, and re-emit the whole thing in the one order C# accepts. When you are inspecting or debugging generic code you are entitled to read, that fidelity is the difference between a signature you can trust and one that quietly changes the contract.

Try Nebula.NET

Harden your .NET code in minutes — start with the free edition.