Decompiling C# pattern matching and switch expressions
A switch expression is not a construct in IL — it lowers to a chain of type tests, null checks and branches, ending in a thrown SwitchExpressionException for the unmatched case. Here is exactly what the compiler emits for type, property, relational and logical patterns, and how a decompiler reads the shape back into a switch.
Modern C# leans hard on pattern matching — type patterns, property patterns, and/or/not, relational patterns, and the switch expression that ties them together. None of it exists in IL. A switch expression compiles to the same primitive control flow a hand-written if/else ladder would, plus one tell-tale detail the compiler adds for safety. Walking the lowering explains both what a decompiler shows you and why the reconstruction is sometimes a near-miss rather than a perfect echo of the source.
A type-pattern switch
Start with a switch expression over types:
public static decimal Price(object shape) => shape switch
{
Circle c => c.Radius * c.Radius * 3.14m,
Rectangle r => r.Width * r.Height,
null => 0m,
_ => throw new ArgumentException("unknown shape"),
};
There is no switch instruction for this. The compiler lowers each arm to a type test, and the only opcode that tests a type without throwing is isinst — it yields the value typed as the target or null, and the code branches on the result:
// shape is Circle c →
ldarg.0
isinst Circle // Circle-or-null
dup
stloc.0 // c
brtrue.s CIRCLE_ARM // matched → compute c.Radius * ...
// shape is Rectangle r →
ldarg.0
isinst Rectangle
dup
stloc.1 // r
brtrue.s RECT_ARM
// shape is null →
ldarg.0
brfalse.s NULL_ARM
That is the signature of a type pattern: isinst immediately followed by a null-check branch (brtrue/brfalse). castclass never appears here, because a pattern that doesn’t match must fall through, not throw — isinst gives exactly those semantics. A decompiler that sees isinst; dup; stloc; brtrue reads it straight back as is Circle c.
The throw that gives it away
The _ arm above threw explicitly, but watch what happens when a switch expression has no catch-all and the compiler can’t prove it is exhaustive:
public static string Name(DayOfWeek d) => d switch
{
DayOfWeek.Saturday => "weekend",
DayOfWeek.Sunday => "weekend",
_ when d >= 0 => "weekday",
};
Even here the compiler must guarantee the expression produces a value or fails deterministically, so it appends a hidden default arm:
// no arm matched →
ldloc.0 // the switched value
newobj instance void [System.Runtime]System.Runtime.CompilerServices.SwitchExpressionException::.ctor(object)
throw
SwitchExpressionException is practically a fingerprint. Almost no hand-written code constructs and throws it; the compiler emits it only for a non-exhaustive switch expression. So a decompiler treats “branch tree that ends in a thrown SwitchExpressionException” as strong evidence the source was a switch expression — and renders a switch { … } rather than an if/else chain, which is what distinguishes the two in the output.
Property, relational and logical patterns
Richer patterns lower to more of the same — member reads and comparisons, combined with ordinary boolean branches. A property pattern with a relational and a logical operator:
string Band(Measurement m) => m switch
{
{ Celsius: < 0 } => "freezing",
{ Celsius: >= 0 and < 100 } => "liquid",
{ Celsius: >= 100 } => "boiling",
_ => "unknown",
};
becomes a null-check on m, then reads of the Celsius property compared with the relational operators, and collapsing to sequential branches that must both pass:
ldloc.0 // m
brfalse UNKNOWN // { ... } requires non-null
ldloc.0
callvirt instance float64 Measurement::get_Celsius()
stloc.1 // cache the property read
ldloc.1
ldc.r8 0.0
bge.s CHECK_LIQUID // not < 0 → try next arm
// → "freezing"
CHECK_LIQUID:
ldloc.1
ldc.r8 0.0
blt.s CHECK_BOILING // < 0 fails the >= 0 half
ldloc.1
ldc.r8 100.0
bge.s CHECK_BOILING
// → "liquid"
Three details are worth noting. First, the property is read once and cached in a temporary even though three arms inspect it — the compiler hoists the read. Second, and is not a special opcode: it is simply two comparisons that both have to pass to reach the arm, and or is two that each branch to it. Third, not typically becomes an inverted branch condition. A decompiler can render these either as the reconstructed pattern (>= 0 and < 100) or as the expanded comparisons; both are faithful, which is why the surface syntax of the output sometimes differs from the source even though the behaviour is identical.
is patterns outside a switch
The same lowering powers a standalone is:
if (node is { Parent: not null } n && n.Depth > 3) { … }
The { Parent: not null } becomes a null test on node, a read of Parent, and a null test on that; the declared n is the isinst/cast result stored to a local; n.Depth > 3 is a plain comparison. There is no pattern metadata to recover — a decompiler reconstructs is { … } from the shape of the tests, which is why clean, regular lowering (as the C# compiler emits) reconstructs well, while heavily optimized or obfuscated branch trees may come back as explicit if checks instead.
What this means when you read decompiled code
Pattern matching is pure syntactic sugar over branches, type tests and comparisons. Three fingerprints let a decompiler rebuild it: isinst + null-branch for type patterns, a hoisted member read feeding relational comparisons for property/relational patterns, and a trailing throw new SwitchExpressionException for a non-exhaustive switch expression. When the lowering is regular, the reconstruction is clean; when it isn’t, you still get correct, readable control flow — just expressed as the if/else the sugar stood for.
Want to see the exact lowering for your own code? Open the assembly in Glass.NET, switch any method to the IL view, and compare it with the C# — the free way to watch pattern matching turn into branches. See also decompiling records and lambdas and closures for more compiler fingerprints.
Try Nebula.NET
Harden your .NET code in minutes — start with the free edition.