The null-conditional operator is one of the most compact things in C#. order?.Customer?.Name says "walk this chain, and the instant any link is null, stop and give me null" — in a single line with no braces and no temporaries. That compactness is entirely a source-language illusion. There is no ?. instruction in IL, no "safe navigation" primitive in the runtime. By the time the compiler is done, the operator has become exactly the loads, calls, and conditional branches you would have written if you had spelled out every null check by hand. Decompiling it well means recognizing that shape and putting the ?. back.
One link
Start with the simplest case, a single ?. over reference types:
string? name = customer?.Name; // customer is Customer?, Name is string
The lowered IL is a null check and a branch:
ldarg.1 // customer
brtrue.s HasValue
ldnull // customer was null -> the whole expression is null
br.s Done
HasValue:
ldarg.1
callvirt instance string Customer::get_Name()
Done:
stloc.0 // name
Nothing exotic: evaluate the receiver, test it with brtrue, and on null push ldnull and skip to the end; on non-null call the getter. The result of both paths lands in the same slot. A decompiler that only ever saw this one case could pattern-match it directly. The reason ?. is interesting to decompile is what happens when you chain it.
A chain shares one skip label
string? name = order?.Customer?.Name;
The C# specification requires the whole chain to short-circuit as a unit — if order is null, Customer is never touched and Name is never touched, and the result is a single null. A naive implementation might nest one null check inside another. The compiler does something cleaner and more revealing: it gives the entire chain one shared skip target, and every link branches to it.
ldloc.0 // order
brtrue.s Link1
Null: // <-- the single shared skip target for the whole chain
ldnull
br.s Done
Link1:
ldloc.0
callvirt instance Customer Order::get_Customer()
dup // intermediate result is a computed value, not a local
brtrue.s Link2
pop // discard the null Customer we just dup-ed
br.s Null // reuse the shared target
Link2:
callvirt instance string Customer::get_Name()
Done:
stloc.1 // name
Two details in this IL carry the whole story. First, the Null: label is reached from both link failures — the brtrue miss on order falls through to it, and the Customer null check branches back to it with br.s Null. There is one null exit for the entire chain, not one per link. That single shared target is the fingerprint of a single ?. chain, and it is exactly what lets a decompiler flatten the IL back into order?.Customer?.Name instead of some nested order?.(Customer?.Name) with redundant checks.
Second, notice the asymmetry between the two null checks. order is a local, so the compiler simply reloads it (ldloc.0) and tests with brtrue — reloading a local is free, so there is no need to keep a copy. But Customer is a computed value returned from a call; you cannot cheaply recompute it, so the compiler dups the result, tests the copy, and pops it on the null path. That dup … brtrue … pop trio around an intermediate member is the tell that an interior link of a ?. chain lives here.
Value types: the Nullable lift
The chain above produced a string, a reference type, so null is just ldnull. When the member after ?. returns a non-nullable value type, the result type of the whole expression is its nullable form — s?.Length is int?, not int, because it has to be able to be null. The compiler implements that with a Nullable<int> local and two different ways of filling it:
int? length = s?.Length; // s is string?, Length is int -> result is int?
ldarg.1 // s
brtrue.s HasValue
ldloca.s V_0 // address of the int? local
initobj valuetype [System.Runtime]System.Nullable`1<int32>
ldloc.0 // the default(int?) we just built: HasValue == false
br.s Done
HasValue:
ldarg.1
callvirt instance int32 System.String::get_Length()
newobj instance void valuetype Nullable`1<int32>::.ctor(!0) // wrap the int
Done:
stloc.0 // length (an int?)
The null path does initobj on the local, which zeroes it — and a zeroed Nullable<int> is HasValue == false, i.e. default(int?). The value path calls get_Length and wraps the raw int with newobj Nullable<int>..ctor. Both converge on the local. This initobj-on-null / Nullable..ctor-on-value pair is the signature of a null-conditional that crossed from a value type into its nullable lift, and a decompiler that recognizes it renders s?.Length rather than surfacing the Nullable plumbing as hand-written code.
The same primitive: ?? and ??=
Once you see ?. as "evaluate once, test for null, branch past," the coalescing operators are obviously the same primitive with different things on each side of the branch. a ?? b:
ldloc.0 // a
dup
brtrue.s Use // a is non-null: keep it, skip b
pop // drop the null
ldloc.1 // b
Use:
a is dup-ed so the non-null copy can stay on the stack as the result; if it is null it is pop-ed and b is evaluated instead. The compound assignment a ??= b is the same test wrapped around a store — "if a is null, assign b to it":
ldsfld Config::_cache
dup
brtrue.s Keep // already set: keep it as the value
pop
call Config::Build()
dup
stsfld Config::_cache // assign, and leave the value on the stack
Keep:
All three operators are built from evaluate-once, test-for-null, branch-past. That shared shape is why a decompiler treats them as a family: it looks for the dup/brtrue/pop idiom and asks what sits on the fallback side — a member access means ?., a plain value means ??, a store back to the source means ??=.
What Glass does with it
Glass.NET decompiles these by recognizing the pattern, not by transcribing the branches. When it sees a receiver evaluated once, null-tested, and short-circuited to a target that yields null or default, it reconstructs ?.; when several such links share a single skip target, it folds them into one flat chain — order?.Customer?.Name — rather than a nest of conditionals. It recognizes the initobj/Nullable..ctor convergence as a nullable lift and writes s?.Length with the right int? type, and it collapses the dup/brtrue/pop idiom into ?? or ??= depending on the fallback. The edges are where it can degrade to equivalent-but-different source: a ?. whose result is discarded in a statement (logger?.Flush();) becomes an if (logger is not null) because there is no value to carry, and a chain mixed with explicit if checks you wrote by hand can read back as either, since the IL no longer distinguishes them. The behavior is identical; only the surface syntax may differ from what you typed.
That is the through-line of decompiling lowered operators: the compiler erases the convenient syntax down to branches, and a good decompiler earns it back by knowing the shape the compiler always emits. See decompiling the iterator state machine and switch on strings for two more operators that vanish into IL and come back only if you know the pattern. Glass.NET is a free .NET decompiler; point it at an assembly that leans on ?. and watch the branches fold back into the operator you wrote.
Top comments (0)