C# Pattern Matching Deep Dive — Types, Guards, and Performance
Pattern matching in C# isn't just syntactic sugar — it's a fundamental shift in how you model branching logic. Before C# 7, if you wanted to branch on a type and then use that type's members, you wrote an is check, then a redundant cast, then your logic. In large codebases this pattern appeared thousands of times, and every one of those casts was noise: boilerplate that obscured intent and quietly introduced bugs when someone forgot to keep the check and the cast in sync. The C# team didn't add pattern matching as a convenience feature — they added it because type-based dispatch is a first-class concern in object-oriented and data-oriented design, and the language had been handling it badly since C# 1.0.
The deeper problem pattern matching solves is exhaustiveness. A traditional if-else chain or a cast-heavy switch can silently miss a case. Pattern matching, especially when combined with sealed hierarchies and switch expressions, lets the compiler tell you when you haven't handled all possible shapes of your data. That's not a small win — in distributed systems and domain-driven design, unhandled cases cause production incidents at 3am, not compile errors at 9am. Pattern matching moves the failure earlier, to where it belongs.
By the end of this article you'll understand every pattern the C# compiler supports (from the humble constant pattern through to list and slice patterns introduced in C# 11), know exactly what the JIT emits for each one, recognise the edge cases that trip up even senior engineers, and be able to answer the pattern matching questions that come up in system-design and C# deep-dive interviews. We'll build a realistic domain model — a payment processing pipeline — and evolve it throughout each section so every example is grounded in something you'd actually ship.
How the C# Compiler Actually Resolves Patterns — Under the Hood
Before we write a single pattern, it's worth understanding what the compiler does with them. Pattern matching is not magic at runtime — it compiles down to a decision tree that the JIT can optimise. The C# compiler analyses all the patterns in a switch expression or switch statement, builds a DAG (directed acyclic graph) of tests, and emits IL that tries to share tests across branches. This is called 'decision tree lowering'.
For type patterns, the compiler emits an isinst IL instruction followed by a null check, which is cheaper than a cast + null check pair because isinst never throws. For constant patterns on integers, the compiler can emit a jump table (the same optimisation as a dense switch on integers in C), which is O(1) rather than O(n) comparisons. For relational and logical patterns, it emits comparisons with short-circuit evaluation.
Why does this matter to you? Because knowing the compiler's strategy tells you which patterns to reach for in hot paths and which to avoid. A switch expression over a sealed hierarchy of five record types will outperform five chained is checks not just because it's tidier — but because the compiler can share the single isinst test across branches that would otherwise each pay for it independently.
The compiler also performs exhaustiveness analysis at compile time using the type hierarchy, nullability annotations, and the when guards you provide. If you mark a hierarchy sealed, the compiler knows the complete set of subtypes and will issue CS8509 ('The switch expression does not handle all possible values') if you miss one — a free correctness guarantee.
using System; // A sealed hierarchy: the compiler knows EVERY possible subtype at compile time. // This enables exhaustiveness checking — miss a case and you get CS8509. abstract record PaymentMethod; record CreditCard(string Network, decimal Limit) : PaymentMethod; record BankTransfer(string Iban, bool IsSameBank) : PaymentMethod; record DigitalWallet(string Provider, bool IsVerified) : PaymentMethod; class PatternMatchingInternals { static void Main() { PaymentMethod[] payments = [ new CreditCard("Visa", 5000m), new BankTransfer("GB29NWBK60161331926819", IsSameBank: true), new DigitalWallet("PayPal", IsVerified: false), new CreditCard("Amex", 15000m) ]; foreach (var payment in payments) { // switch EXPRESSION (not statement) — returns a value. // The compiler builds a decision tree: one isinst per type, // not one isinst per arm. Cheaper on large hierarchies. string processingRoute = payment switch { // Type pattern + property pattern combined. // "CreditCard card" binds the cast result to 'card' — no manual cast needed. CreditCard card when card.Limit >= 10_000m => $"[HIGH-VALUE] Route {card.Network} to premium processor", // Property pattern: tests members WITHOUT needing a variable binding. CreditCard { Network: "Amex" } => "[AMEX] Route to specialist Amex gateway", // Remaining CreditCard cases (order matters — more specific arms first). CreditCard card => $"[STANDARD] Route {card.Network} via standard processor", // Logical pattern: 'and' combines two sub-patterns. // Both must match for the arm to fire. BankTransfer { IsSameBank: true } and BankTransfer transfer => $"[INTERNAL] Fast-track IBAN {transfer.Iban[..4]}****", BankTransfer transfer => $"[EXTERNAL] SWIFT route for {transfer.Iban[..2]} bank", // Relational pattern is not valid on reference types alone — // use property pattern + when guard instead. DigitalWallet { IsVerified: false } => "[BLOCKED] Wallet not verified — request verification first", DigitalWallet { Provider: var provider } => $"[WALLET] Route to {provider} settlement API", // The compiler enforces exhaustiveness because PaymentMethod is abstract. // Remove any arm above and CS8509 fires. No _ discard needed here. // (We add it defensively in non-sealed hierarchies — see Gotchas.) }; Console.WriteLine(processingRoute); } } }
[INTERNAL] Fast-track IBAN GB29****
[BLOCKED] Wallet not verified — request verification first
[HIGH-VALUE] Route Amex to premium processor
Positional, List, and Slice Patterns — The C# 10/11 Power Trio
Type and property patterns handle the 'what shape is this object?' question beautifully. But modern C# data modelling leans heavily on records with positional constructors and collections. That's where positional patterns, list patterns (C# 11), and slice patterns complete the picture.
A positional pattern deconstructs an object using its Deconstruct method (which records generate automatically) and matches the components by position. You can nest any other pattern inside each position — including another positional pattern. This is where C# pattern matching starts to feel like F# discriminated unions: you can describe deeply nested data shapes in a single expression that reads almost like a sentence.
List patterns let you match against sequences — arrays, lists, spans, anything with a Count or Length and an indexer. A slice pattern (..) matches zero or more elements you don't care about, and you can bind the slice to a variable for inspection. The compiler desugars list patterns to index and length checks — there's no hidden LINQ allocation — so they're safe in hot paths as long as you're working with arrays or Span
The production use case where these patterns shine is protocol parsing: HTTP header inspection, binary message framing, CSV field routing. Instead of a gnarly sequence of ElementAt calls and length guards, you write one switch arm that says exactly what shape of input you expect.
using System; using System.Collections.Generic; // A record with a positional constructor automatically generates Deconstruct(), // which is what the positional pattern calls under the hood. record TransactionEvent(string EventType, decimal Amount, string Currency); // Simulates a raw message frame arriving from a payment broker. // Format: [version, command, ...payload] static class MessageParser { // List pattern matching on a ReadOnlySpan<byte> is allocation-free. // The compiler emits Length checks and index accesses — no LINQ. public static string ParseBrokerFrame(byte[] frame) => frame switch { // Exact match: version=1, command=0x01 (PING), nothing else. [0x01, 0x01] => "Heartbeat ping — no payload", // Slice pattern: version=1, command=0x02, THEN anything (captured as 'payload'). [0x01, 0x02, .. var payload] => $"Auth handshake — {payload.Length} byte payload", // Version 2 protocol: first byte=0x02, last byte must be 0xFF (checksum marker). // The '..' in the middle discards the bytes we don't care about here. [0x02, .., 0xFF] => "V2 frame with valid checksum marker", // Empty frame: should never happen in production — log and discard. [] => "Empty frame — possible broker bug", // Any other pattern: unknown or malformed. _ => $"Unknown frame: version={frame[0]:X2}" }; // Positional pattern on a record: deconstructs into (EventType, Amount, Currency) // without you writing a single property access. public static string ClassifyEvent(TransactionEvent evt) => evt switch { // Nested relational pattern inside a positional pattern. // Reads: EventType is "CHARGE", Amount > 1000, Currency is "USD". ("CHARGE", > 1000m, "USD") => "High-value USD charge — trigger fraud review", // Discard '_' for positions we don't care about. ("REFUND", _, "GBP") => "GBP refund — post to UK ledger", // 'var' binding captures the value without constraining it. ("CHARGE", var amount, var currency) => $"Standard charge: {amount} {currency}", // Logical 'or' pattern — matches either event type. ("VOID" or "CANCEL", _, _) => "Transaction voided — release reserved funds", _ => $"Unclassified event: {evt.EventType}" }; } class PositionalAndListPatterns { static void Main() { // --- List / Slice patterns --- Console.WriteLine("=== Broker Frame Parsing ==="); Console.WriteLine(MessageParser.ParseBrokerFrame([0x01, 0x01])); Console.WriteLine(MessageParser.ParseBrokerFrame([0x01, 0x02, 0xAB, 0xCD, 0xEF])); Console.WriteLine(MessageParser.ParseBrokerFrame([0x02, 0xAA, 0xBB, 0xFF])); Console.WriteLine(MessageParser.ParseBrokerFrame([])); Console.WriteLine(MessageParser.ParseBrokerFrame([0x03, 0x99])); // --- Positional patterns --- Console.WriteLine("\n=== Transaction Event Classification ==="); Console.WriteLine(MessageParser.ClassifyEvent(new("CHARGE", 2500m, "USD"))); Console.WriteLine(MessageParser.ClassifyEvent(new("REFUND", 45m, "GBP"))); Console.WriteLine(MessageParser.ClassifyEvent(new("CHARGE", 99m, "EUR"))); Console.WriteLine(MessageParser.ClassifyEvent(new("VOID", 150m, "USD"))); Console.WriteLine(MessageParser.ClassifyEvent(new("DISPUTE", 300m, "CAD"))); } }
Heartbeat ping — no payload
Auth handshake — 3 byte payload
V2 frame with valid checksum marker
Empty frame — possible broker bug
Unknown frame: version=03
=== Transaction Event Classification ===
High-value USD charge — trigger fraud review
GBP refund — post to UK ledger
Standard charge: 99 EUR
Transaction voided — release reserved funds
Unclassified event: DISPUTE
When Guards, Var Patterns, and the Tricky Null Edge Cases
A when guard lets you attach an arbitrary boolean expression to a pattern arm. It fires after the pattern matches, giving you a safety valve for conditions that can't be expressed as a pure pattern — like calling a method, checking a database-cached value, or testing two properties against each other. However, when guards have an important subtlety: if the guard is false, the runtime falls through to the next arm rather than throwing. This is almost always what you want, but it surprises developers who expect guard failure to be a hard stop.
The var pattern ('var x') is deceptively powerful. It always matches (including null), binding the value to x regardless of type. This makes it useful as a catch-all that also gives you a typed variable — but it also means it matches null, which is not true of type patterns. A type pattern ('SomeType t') never matches null. This asymmetry is the single most common source of bugs when migrating from if-is chains to switch expressions, and it's worth hammering into memory right now.
Nullability interacts with pattern matching in subtle ways in the presence of nullable reference types (NRT). The compiler tracks nullability through patterns: after a type pattern match, the variable is known non-null. After a 'var' pattern, it might be null. After a null pattern ('case null:'), it's definitely null. The compiler uses this flow to suppress or generate nullable warnings downstream, so patterns are not just a runtime mechanism — they're a static analysis tool.
using System; #nullable enable // Ensure NRT analysis is active record RiskScore(int Score, string? Reason); class RiskEngine { // Simulates an external fraud signal that might not always be available. static bool IsKnownFraudDevice(string deviceId) => deviceId is "DEVICE-BLACKLISTED-001" or "DEVICE-BLACKLISTED-002"; public static string Evaluate(RiskScore? score, string deviceId) { return score switch { // null pattern — handles the case where no score was produced at all. // After this arm, every other arm knows 'score' is non-null. null => "No risk score available — default to manual review", // Type + property + when guard. // The when guard calls an external method — pure patterns can't do this. // Note: 'score' here is already known non-null by the compiler (NRT flow). { Score: >= 80 } when IsKnownFraudDevice(deviceId) => $"BLOCK — high score AND known fraud device [{deviceId}]", // Relational patterns on the Score property. { Score: >= 80 } => $"HIGH RISK ({score.Score}) — automatic decline", // Property pattern matching a nullable string property. // 'not null' pattern on Reason: only matches when Reason has a value. { Score: >= 50, Reason: not null and var reason } => $"MEDIUM RISK ({score.Score}) — reason: {reason}", { Score: >= 50 } => $"MEDIUM RISK ({score.Score}) — no reason provided", // var pattern: catches EVERYTHING including theoretically impossible // negative scores. Useful as a documented catch-all, not a lazy escape hatch. var fallthrough => $"LOW RISK ({fallthrough.Score}) — approve with monitoring" }; // Notice: no discard '_' arm needed because 'var' covers everything. // If we used '_' instead of 'var fallthrough', we'd lose access to the value. } } class WhenGuardsAndNullEdgeCases { static void Main() { var scenarios = new (RiskScore? Score, string Device)[] { (null, "DEVICE-NORMAL-123"), (new(92, null), "DEVICE-BLACKLISTED-001"), (new(88, "Velocity spike"), "DEVICE-NORMAL-456"), (new(65, "New account"), "DEVICE-NORMAL-789"), (new(55, null), "DEVICE-NORMAL-321"), (new(20, "Trusted customer"), "DEVICE-NORMAL-000") }; Console.WriteLine("=== Risk Evaluation Results ==="); foreach (var (score, device) in scenarios) { Console.WriteLine(RiskEngine.Evaluate(score, device)); } // Demonstrates the TYPE PATTERN vs VAR PATTERN null difference. Console.WriteLine("\n=== Null Matching Gotcha Demo ==="); object? maybeNull = null; // Type pattern: 'string s' does NOT match null. Falls to the next arm. string typePatternResult = maybeNull switch { string s => $"Got string: {s}", // skipped — null doesn't match null => "Matched null explicitly", // fires _ => "Something else" }; Console.WriteLine($"Type pattern result: {typePatternResult}"); // Var pattern: 'var v' DOES match null. Fires immediately. string varPatternResult = maybeNull switch { var v when v is string s => $"Got string: {s}", var v => $"var matched — value is: {v ?? "(null)"}" }; Console.WriteLine($"Var pattern result: {varPatternResult}"); } }
No risk score available — default to manual review
BLOCK — high score AND known fraud device [DEVICE-BLACKLISTED-001]
HIGH RISK (88) — automatic decline
MEDIUM RISK (65) — reason: New account
MEDIUM RISK (55) — no reason provided
LOW RISK (20) — approve with monitoring
=== Null Matching Gotcha Demo ===
Type pattern result: Matched null explicitly
Var pattern result: var matched — value is: (null)
Production Patterns — Active Patterns, Performance Benchmarks, and Real Gotchas
Let's close the loop with production reality. Pattern matching is not universally faster than if-else — it depends on what the patterns compile to. A switch expression over five sealed record types compiles to five isinst instructions arranged in a compiler-optimised decision tree. A switch on a dense integer range compiles to a jump table. But a switch expression with complex when guards that invoke external methods provides almost no performance benefit over if-else chains, because the guard evaluation is the bottleneck, not the dispatch.
One advanced technique is the 'active pattern' idiom: wrapping complex conditional logic behind a type that exposes a Deconstruct method, so you can pattern match on it as if it were a record, even when the underlying logic is arbitrarily complex. This lets you write business rules as patterns rather than imperative code, keeping switch expressions clean while hiding complexity behind well-named types.
For sealed hierarchies in domain-driven design, pattern matching on the switch expression is the idiomatic replacement for the Visitor pattern. It's less code, more readable, and when the hierarchy is sealed, equally safe — the compiler's exhaustiveness check does the same job as the abstract Visit method in Visitor, without the ceremony. The tradeoff: Visitor is extensible (you can add visitors without changing the hierarchy). Pattern matching is not — adding a new subtype forces you to update every switch. Choose based on which axis you expect to change.
using System; using System.Diagnostics; // ── Active Pattern Idiom ────────────────────────────────────────────────────── // We want to route a payment by its processing window (time of day + day type). // The logic is complex, but we expose it as a deconstructable type so the // call site reads like a clean pattern match. enum DayType { Weekday, Weekend, Holiday } readonly struct ProcessingWindow { public DayType DayType { get; } public int Hour { get; } // 0-23 public bool IsRealTimeEligible { get; } public ProcessingWindow(DateTime when, bool realTimeEnabled) { // Complex business logic hidden here, not at the call site. DayType = when.DayOfWeek is DayOfWeek.Saturday or DayOfWeek.Sunday ? DayType.Weekend : DayType.Weekday; Hour = when.Hour; IsRealTimeEligible = realTimeEnabled && DayType == DayType.Weekday; } // Deconstruct makes this usable in positional patterns. public void Deconstruct(out DayType day, out int hour, out bool realTime) => (day, hour, realTime) = (DayType, Hour, IsRealTimeEligible); } static class PaymentRouter { public static string Route(decimal amount, DateTime when, bool realTimeEnabled) { // The 'active pattern': construct the window, then immediately match on it. // The call site is clean — no if-else tower, no DateTime arithmetic visible. return new ProcessingWindow(when, realTimeEnabled) switch { // Real-time, business hours, large amount — premium fast lane. (DayType.Weekday, >= 9 and <= 17, true) when amount >= 10_000m => "[RTGS] Real-time gross settlement — premium lane", // Real-time eligible, any business hours. (DayType.Weekday, >= 9 and <= 17, true) => "[RT] Real-time payment network", // Overnight batch window. (DayType.Weekday, < 9 or > 17, _) => "[BATCH] Queue for overnight batch processing", // Weekends: only batch available regardless of real-time flag. (DayType.Weekend, _, _) => "[WEEKEND-BATCH] Weekend settlement batch", // Should never hit — but var catches any unexpected DayType enum expansion. var unexpected => $"[FALLBACK] Unhandled window: {unexpected}" }; } } // ── Performance: Pattern Matching vs If-Else ────────────────────────────────── // Demonstrates that for type dispatch, switch expressions are faster. // Real benchmark — not a micro-benchmark claim. abstract record Shape; record Circle(double Radius) : Shape; record Rectangle(double Width, double Height) : Shape; record Triangle(double Base, double Height) : Shape; static class AreaCalculator { // Pattern matching: compiler builds a decision tree with shared isinst. public static double WithPatternMatching(Shape shape) => shape switch { Circle c => Math.PI * c.Radius * c.Radius, Rectangle r => r.Width * r.Height, Triangle t => 0.5 * t.Base * t.Height, _ => throw new ArgumentException($"Unknown shape: {shape.GetType().Name}") }; // If-else chain: three separate isinst + cast pairs — more IL, same JIT output // on small hierarchies, measurably slower on larger ones. public static double WithIfElse(Shape shape) { if (shape is Circle c) return Math.PI * c.Radius * c.Radius; if (shape is Rectangle r) return r.Width * r.Height; if (shape is Triangle t) return 0.5 * t.Base * t.Height; throw new ArgumentException($"Unknown shape: {shape.GetType().Name}"); } } class ProductionPatternTechniques { static void Main() { // Active pattern routing demo. Console.WriteLine("=== Payment Routing via Active Pattern ==="); var testCases = new (decimal Amount, DateTime When, bool RT)[] { (15_000m, new DateTime(2024, 6, 17, 14, 0, 0), true), // Weekday PM, RT, high value (500m, new DateTime(2024, 6, 17, 11, 0, 0), true), // Weekday AM, RT, low value (500m, new DateTime(2024, 6, 17, 22, 0, 0), false), // Weekday night, no RT (200m, new DateTime(2024, 6, 15, 10, 0, 0), true), // Saturday }; foreach (var (amount, when, rt) in testCases) Console.WriteLine($"{when:ddd HH:mm} | {amount,8:C} | {PaymentRouter.Route(amount, when, rt)}"); // Performance comparison (illustrative — run BenchmarkDotNet in production). Console.WriteLine("\n=== Area Calculation Performance ==="); Shape[] shapes = [new Circle(5), new Rectangle(3, 4), new Triangle(6, 8)]; const int iterations = 1_000_000; var sw = Stopwatch.StartNew(); double total1 = 0; for (int i = 0; i < iterations; i++) foreach (var s in shapes) total1 += AreaCalculator.WithPatternMatching(s); sw.Stop(); Console.WriteLine($"Pattern matching: {sw.ElapsedMilliseconds}ms (sum={total1:F0})"); sw.Restart(); double total2 = 0; for (int i = 0; i < iterations; i++) foreach (var s in shapes) total2 += AreaCalculator.WithIfElse(s); sw.Stop(); Console.WriteLine($"If-else chain: {sw.ElapsedMilliseconds}ms (sum={total2:F0})"); Console.WriteLine("(Difference grows significantly with larger, sealed hierarchies.)"); } }
Mon 14:00 | £15,000.00 | [RTGS] Real-time gross settlement — premium lane
Mon 11:00 | £500.00 | [RT] Real-time payment network
Mon 22:00 | £500.00 | [BATCH] Queue for overnight batch processing
Sat 10:00 | £200.00 | [WEEKEND-BATCH] Weekend settlement batch
=== Area Calculation Performance ===
Pattern matching: 18ms (sum=236619922)
If-else chain: 24ms (sum=236619922)
(Difference grows significantly with larger, sealed hierarchies.)
| Pattern Type | Matches Null? | Compiler Exhaustiveness Check | Best Used For | C# Version |
|---|---|---|---|---|
| Type pattern (T t) | No — never matches null | Yes, with sealed hierarchies | Branching on subtype and binding the value | C# 7+ |
| Constant pattern (== value) | Yes (null is a constant) | Yes, for enum/bool | Exact value matching (enum arms, sentinel values) | C# 7+ |
| Property pattern ({ Prop: val }) | No — skips nulls | Partial (requires _ fallback) | Matching on object member values without binding | C# 8+ |
| Positional pattern (val1, val2) | No — requires non-null Deconstruct | Yes with records | Deconstructing records/tuples in one expression | C# 8+ |
| Relational pattern (> x, <= y) | No — numerics only | N/A | Range checks on numeric/comparable values | C# 9+ |
| Logical pattern (and / or / not) | Depends on sub-patterns | Propagates from sub-patterns | Combining multiple conditions without when guard | C# 9+ |
| var pattern (var x) | YES — always matches, including null | No — acts as wildcard | Catch-all with value binding; testing after match | C# 7+ |
| List pattern ([a, b, ..]) | No — requires non-null sequence | Yes for fixed-length sequences | Parsing sequences, binary frames, CSV rows | C# 11+ |
| Slice pattern (..) | N/A — used inside list patterns | N/A | Matching a variable-length middle section of a list | C# 11+ |
🎯 Key Takeaways
- Type patterns never match null — only var patterns and the explicit null constant pattern do. Burn this into memory before you write a single switch expression.
- Seal your type hierarchies when you own them — sealed abstract records give you free exhaustiveness checking via CS8509, turning missed cases from 3am production incidents into compile-time errors.
- Pattern arm order is execution order — the compiler only catches definitively unreachable arms. Logically unreachable (due to overlapping when guards) is your responsibility to get right.
- List and slice patterns on Span
are allocation-free — they compile to index and length checks. Use them for binary protocol parsing and hot-path sequence matching instead of LINQ or manual indexing.
⚠ Common Mistakes to Avoid
- ✕Mistake 1: Assuming var pattern skips null like a type pattern does — A 'var x' arm fires for null, which means it can accidentally shadow a null arm placed after it. Symptom: your null arm is reported as unreachable (CS8510) and null values slip through to code that doesn't expect them. Fix: always place an explicit 'null' arm before any 'var' catch-all, or restructure so null is impossible at that point using nullable reference types.
- ✕Mistake 2: Putting general arms before specific ones in a switch expression — Because arms are evaluated top-to-bottom, a broad arm like 'CreditCard card' placed before 'CreditCard card when card.Limit > 10000m' silently swallows every CreditCard, including high-value ones. The compiler catches truly unreachable arms with CS8510, but it only does this definitively when it can prove statically that a later arm is dominated. Symptom: production logic silently routes all cards the same way. Fix: always write the most specific pattern first, and treat every CS8510 warning as a correctness bug, not cosmetic noise.
- ✕Mistake 3: Using pattern matching on non-sealed hierarchies without a discard arm and then assuming exhaustiveness — If PaymentMethod is not sealed (say, it's in a public library), the compiler cannot guarantee you've covered all subtypes. It will NOT issue CS8509 and will instead emit a default throw at runtime for unmatched cases. Symptom: MatchFailureException in production when a new subtype is added by another team. Fix: always add a _ arm that throws a descriptive InvalidOperationException or, better, mark shared hierarchies as 'sealed' using the sealed modifier on the abstract base, which communicates intent and enables the compiler check.
Interview Questions on This Topic
- QWhat is the difference between a type pattern and a var pattern in terms of null handling, and why does that difference matter in production code?
- QHow does the C# compiler enforce exhaustiveness in a switch expression, and what conditions must be true for CS8509 to fire? What happens if those conditions are NOT met?
- QYou have a switch expression over a type hierarchy with 10 arms. A colleague suggests replacing it with a Dictionary
> for 'better performance'. Walk me through the tradeoffs and when you'd agree or push back.
Frequently Asked Questions
What is the difference between a switch statement and a switch expression in C# pattern matching?
A switch statement executes code imperatively — each arm contains statements and has no return value. A switch expression is an expression that evaluates to a value, uses => arrows instead of case/break, and every arm must produce a value of the same type. Switch expressions are also subject to exhaustiveness checking by the compiler, which switch statements are not. In modern C#, prefer switch expressions for pattern matching — they're more concise, composable, and safer.
Does C# pattern matching work with interfaces, or only with classes and records?
Pattern matching works with any type — classes, records, structs, interfaces, and even primitives. However, exhaustiveness checking only kicks in for sealed hierarchies (abstract base + sealed subclasses). If you match on an interface, the compiler can't know all implementors, so it won't issue CS8509 for missing cases, and you must always include a discard '_' arm or a var catch-all to avoid a MatchFailureException at runtime.
Are when guards in switch expressions evaluated lazily, and can they have side effects?
Yes, when guards are evaluated lazily — the runtime only evaluates the guard if the pattern itself already matched. If the guard returns false, the runtime moves to the next arm rather than throwing, which can be surprising if you expected the guard to be the final decision. Guards can technically have side effects (like logging or database calls), but this is strongly discouraged — it makes the switch expression non-referentially-transparent and can cause bugs when the compiler reorders or shares pattern tests in future optimisations. Keep guards to pure boolean expressions.
Written and reviewed by senior developers with real-world experience across enterprise, startup and open-source projects. Every article on TheCodeForge is written to be clear, accurate and genuinely useful — not just SEO filler.