Skip to content
← All posts
· Delta1 Labs Glass.NETDecompilation.NETDeep Dive

Decompiling nullable value types: int? is a struct, a lifted operator is a HasValue check, and the box that erases Nullable<T>

int? looks like a type with a null in it, and in the IL it is neither: it is the generic struct System.Nullable<int>, two fields and no null anywhere, and every operation the language lets you write on it is rewritten by the compiler into calls on that struct. a + b on two int? values becomes a HasValue conjunction, two GetValueOrDefault calls, an add, and a newobj — or an initobj for the null result. a == b becomes a value comparison ANDed with a HasValue comparison. a == null becomes a single HasValue call. a ?? 5 becomes a conditional over HasValue. And boxing an int? does something no other struct does: the box instruction yields either a boxed int or a null reference, so Nullable<T> never exists on the heap at all. This post reads each of those lowerings in IL and follows a decompiler through folding them back into the operators the author wrote — why the HasValue and GetValueOrDefault pattern has to be recognised as a unit, why int? comes from the signature blob rather than any attribute (unlike string?), how unbox.any on a null reference quietly produces an empty Nullable, and what a decompiler gets wrong when it renders the lowering instead of the language.

Everything the C# language lets you do with int? is a lie the compiler tells on behalf of a struct. There is no null in an int?. There is no special “nullable integer” type the CLR knows how to add. There is System.Nullable<int> — two fields, sixteen bytes, a value type like any other — and a compiler that rewrites a + b, a == null, a ?? 5 and (object)a into calls on that struct so thoroughly that the operators you typed never reach the IL. Reading an obfuscated or symbol-less assembly that uses nullable arithmetic means reading those rewrites, and a decompiler that renders them literally produces code that is correct, compilable, and unreadable.

This post follows each lowering through the IL and back out, and shows what Glass.NET has to recognise to give you the line you wrote.

int? is in the signature blob, not in an attribute

First the easy part, because it is the opposite of a trap readers of nullable reference types have learned to expect. string? leaves the type alone and records nullability in [Nullable] attributes — compile-time annotations the runtime ignores. int? is a different type: the generic struct Nullable<T> instantiated over int, and it appears as such in every signature:

.method public hidebysig static valuetype [System.Runtime]System.Nullable`1<int32>
        Add(valuetype [System.Runtime]System.Nullable`1<int32> a,
            valuetype [System.Runtime]System.Nullable`1<int32> b) cil managed

A decompiler prints int? for valuetype System.Nullable`1<int32> by the same rule it prints int for System.Int32 — the language’s shorthand for a known type. No attribute is consulted; nothing can be stripped that would lose it. Metadata hardening that removes [Nullable] annotations leaves int? completely intact, because the information was never an annotation.

The struct itself is two fields and a handful of members, and those members are the entire vocabulary of every lowering that follows:

public struct Nullable<T> where T : struct
{
    private readonly bool hasValue;
    internal T value;
    public Nullable(T value);              // the "has a value" constructor
    public bool HasValue { get; }
    public T Value { get; }                // throws InvalidOperationException when !hasValue
    public T GetValueOrDefault();          // returns value unconditionally — no check, no throw
}

default(Nullable<T>) — both fields zero — is what the language calls null.

Lifted arithmetic: a conjunction, two GetValueOrDefaults, and a newobj

Take the method whose signature appears above:

static int? Add(int? a, int? b) => a + b;

The + on two int? values is a lifted operator: the result is null if either operand is null, otherwise the sum. The compiler emits that rule directly:

  .locals init (valuetype Nullable`1<int32> V_0,
                valuetype Nullable`1<int32> V_1,
                valuetype Nullable`1<int32> V_2)
  IL_0000: ldarg.0
  IL_0001: stloc.0                                   // copy a to a temp (operands evaluated once)
  IL_0002: ldarg.1
  IL_0003: stloc.1                                   // copy b
  IL_0004: ldloca.s   V_0
  IL_0006: call       instance bool Nullable`1<int32>::get_HasValue()
  IL_000b: ldloca.s   V_1
  IL_000d: call       instance bool Nullable`1<int32>::get_HasValue()
  IL_0012: and                                       // both have a value?
  IL_0013: brtrue.s   IL_0020
  IL_0015: ldloca.s   V_2
  IL_0017: initobj    Nullable`1<int32>              // the null result: default(Nullable<int>)
  IL_001d: ldloc.2
  IL_001e: ret
  IL_0020: ldloca.s   V_0
  IL_0022: call       instance !0 Nullable`1<int32>::GetValueOrDefault()
  IL_0027: ldloca.s   V_1
  IL_0029: call       instance !0 Nullable`1<int32>::GetValueOrDefault()
  IL_002e: add
  IL_002f: newobj     instance void Nullable`1<int32>::.ctor(!0)
  IL_0034: ret

Three details are worth noticing, because they are what a decompiler keys on:

  • The operands are copied to temporaries first, so each is evaluated exactly once even though it is read twice (once for HasValue, once for the value). A hand-written version would usually not bother.
  • The value is read with GetValueOrDefault, not Value. HasValue was just checked, so the re-check inside Value would be redundant and its throw path unreachable; GetValueOrDefault is a plain field read. Seeing GetValueOrDefault immediately after a HasValue guard is the single strongest signal that you are looking at compiler output rather than source.
  • The null result is initobj, not a call — there is no “null constructor”; the null state is the struct zeroed.

Render that literally and you get:

// faithful to the IL, and not what anyone wrote:
int? num = a, num2 = b;
return (num.HasValue & num2.HasValue)
    ? new int?(num.GetValueOrDefault() + num2.GetValueOrDefault())
    : null;

That compiles and is semantically exact. It is also three lines, two temporaries and four calls for a single +. Glass recognises the whole shape — temporaries, conjunction of HasValue, a pair of GetValueOrDefault feeding one binary operator, newobj on the true arm and initobj on the false — and emits a + b. The same template covers every lifted arithmetic, bitwise and shift operator; only the instruction between the two GetValueOrDefault calls changes.

Lifted + : lowering and refoldingsourceint? r = a + b;guardV_0=a; V_1=bV_0.HasValue & V_1.HasValuefalse arm → nullinitobj Nullabletrue arm → valueGetValueOrDefault ×2; add; newobjdecompiled (Glass)int? r = a + b;signals of a loweringGetValueOrDefault readright after a HasValueguard (never .Value)operands copied totemporaries firstinitobj — not a call —for the null resultone operator per shapeRendered literally: (V_0.HasValue & V_1.HasValue) ? new int?(V_0.GetValueOrDefault() + V_1.GetValueOrDefault()) : null

Lifted equality is a different shape

Comparison is not lowered the same way, because the rule is different: two nulls are equal, and a null is unequal to any value. The compiler produces a branch-free expression for it:

static bool Same(int? a, int? b) => a == b;
  IL_0004: ldloca.s   V_0
  IL_0006: call       instance !0 Nullable`1<int32>::GetValueOrDefault()
  IL_000b: ldloca.s   V_1
  IL_000d: call       instance !0 Nullable`1<int32>::GetValueOrDefault()
  IL_0012: ceq                                       // values equal (both 0 when both null)
  IL_0014: ldloca.s   V_0
  IL_0016: call       instance bool Nullable`1<int32>::get_HasValue()
  IL_001b: ldloca.s   V_1
  IL_001d: call       instance bool Nullable`1<int32>::get_HasValue()
  IL_0022: ceq                                       // same has-value state
  IL_0024: and
  IL_0025: ret

That is (a.GetValueOrDefault() == b.GetValueOrDefault()) & (a.HasValue == b.HasValue). Walk the cases: both null → 0 == 0 and false == false → true. One null, one 5 → 0 == 5 is false → false. Both 5 → true. Note that GetValueOrDefault is called here without a prior HasValue guard — it is being used precisely because it is safe on a null — so the “GetValueOrDefault after guard” signal from the arithmetic case does not apply, and the decompiler needs a second template for this shape. <, >, <=, >= lower to the value comparison ANDed with a.HasValue & b.HasValue (two nulls are not less-than each other), which is a third variant of the same idea.

== null, ??, and the pattern is int v

The simpler forms have simpler lowerings, each of which is a single-template match:

bool IsNull(int? a) => a == null;        // ldarga a; call get_HasValue; ldc.i4.0; ceq
int  OrFive(int? a) => a ?? 5;           // a.HasValue ? a.GetValueOrDefault() : 5
int  Force (int? a) => (int)a;           // ldarga a; call get_Value   — the only place Value appears
bool Test  (int? a) => a is int v && v > 0;   // a.HasValue && a.GetValueOrDefault() > 0

== null never compares a reference: there is no reference. It is !a.HasValue, and != null is a.HasValue. ?? becomes a conditional on HasValue with GetValueOrDefault on the value arm — again not Value, because the guard already ran. The explicit cast (int)a is the only lowering that calls get_Value, because it is the only one whose contract is to throw when there is nothing there; a decompiler can use that as a tell. And the type pattern a is int v over a nullable is lowered to HasValue plus GetValueOrDefault into v, with no isinst anywhere — the runtime type test the pattern looks like never happens, because the compiler already knows what the struct contains.

The box that erases Nullable<T>

Everything so far is the compiler rewriting language into calls. Boxing is different: here the runtime does something special, and the IL says less than it seems to.

object Box(int? a)   => a;              // box valuetype Nullable`1<int32>
int?   Unbox(object o) => (int?)o;      // unbox.any valuetype Nullable`1<int32>

Boxing an ordinary struct copies it to the heap behind an object header. The box instruction applied to Nullable<T> does not do that. The runtime checks hasValue: if it is false, the result is a null reference; if it is true, the result is a boxed T — a heap object of type System.Int32, byte-for-byte identical to boxing a plain int. Nullable<int> itself never reaches the heap. The consequences are visible from C#:

object o = (int?)5;
o.GetType()            // typeof(int) — not Nullable<int>
o is int               // true
o is int?              // true — same object satisfies both
object n = (int?)null; // n == null; there is no "boxed empty nullable"

unbox.any Nullable<int32> is the mirror image: given a boxed int it constructs a Nullable<int> with a value; given a null reference it produces an empty Nullable<int> instead of throwing the NullReferenceException that unbox.any on any other value type would throw. So (int?)o is the one cast in C# that quietly turns null into a value-type default.

For a decompiler this is a case of not inventing code. The box and unbox.any instructions name Nullable<int32> in their type operand, and the temptation is to render them as a conversion call or a new int?(…). The author wrote a plain implicit conversion and a plain cast; the special behaviour lives inside the runtime’s implementation of two ordinary instructions, and the right output is object o = a; and (int?)o, nothing more.

What a decompiler gets wrong, and why it matters

Three failure modes show up in practice, and they are all versions of rendering the lowering instead of the language:

  1. The HasValue/GetValueOrDefault dance printed in full — correct, verbose, and a reliable sign the tool has no lifted-operator templates. A method with six nullable additions becomes forty lines of temporaries.
  2. new int?(x) where the source had x — the implicit conversion from int to int? is a newobj in the IL, and printing the constructor call is faithful but noisy. Glass emits the conversion implicitly wherever the target type makes it unambiguous.
  3. A conversion call around box/unbox.any — inventing a method that is not in the IL to explain behaviour that is in the runtime.

The reason these matter beyond aesthetics is that nullable arithmetic clusters in exactly the code a reader is usually trying to understand — optional configuration, financial calculations with “not set” states, pattern-heavy domain logic — and the lowering multiplies its length by three or four. A decompiler that folds the templates back gives you the author’s five lines; one that does not gives you a page of struct calls that all look the same.

The underlying lesson is the same one that governs ?. and ?? and foreach: the C# compiler lowers language features into ordinary IL over ordinary types, and the decompiler’s job is to know every one of those shapes well enough to run them backwards. Nullable<T> is unusual only in how completely it disappears — into a struct on the way in, and into a plain boxed int or a null on the way out.

Try Nebula.NET

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