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):0x0non-variant,0x1covariant (out),0x2contravariant (in). Only ever non-zero on interface and delegate parameters. - The
SpecialConstraintMask:0x04reference-type constraint (class),0x08value-type / non-nullable constraint (struct),0x10default-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:
-
notnullis carried by aSystem.Runtime.CompilerServicesattribute on theGenericParam, with no special-constraint flag set. A reader that only consultsSpecialConstraintMasksees nothing and emits an unconstrained parameter, dropping the constraint entirely. -
unmanagedis encoded as thestructflag (0x08) plus aGenericParamConstraintrow whose type carries a required modifier — amodreqofSystem.Runtime.CompilerServices.IsUnmanagedAttribute— on aSystem.ValueTypeconstraint. A reader that sees only thestructbit emitswhere T : struct, which is a strictly weaker constraint thanunmanagedand silently lets managed value types through.
In IL the unmanaged case looks like a struct-flagged parameter with a modreq'd ValueType constraint:
plaintext
.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:
csharp
// 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:
csharp
// 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:
-
Read the
VarianceMaskfromGenericParam.Flags. Covariant becomesout, contravariant becomesin, and the keyword is placed in the declaration (IProducer<out T>), not thewhereclause. Variance is only read for interface and delegate parameters, where it is legal. -
Read the
SpecialConstraintMaskfor theclass,struct, andnew()bits — but before trusting a barestructbit, check whether theValueTypeconstraint carries theIsUnmanagedAttributemodreq, in which case the constraint isunmanaged, notstruct. Check theGenericParamattributes for thenotnullmarker too. -
Read every
GenericParamConstraintrow for the parameter and resolve eachTypeDefOrRefto a base class or interface. A class type goes in the base-type bucket; interfaces go in the interface bucket. -
Sort into the fixed C# order and emit: the
class/struct/unmanaged/notnullconstraint first, then the base type, then the interfaces, thennew()last. Buckets that are empty are skipped, and a parameter with no constraints at all gets nowhereclause.
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.
Top comments (0)