Skill v1.0.0
currentAutomated scan100/100version: "1.0.0" name: csharp-pattern-matching description: Use when writing or reviewing C# conditionals, type checks, or branching logic — switch expressions vs if/else chains, property/positional/relational/logical/type/list patterns, is not null, when guards, exhaustiveness (and why it doesn't apply to enums), deconstruction, and when a pattern helps readability vs when it hurts it.
Pattern matching — decide with patterns, not chained ifs
Patterns replace nests of if/else if/casts with declarative shape checks. The compiler lowers them into an efficient decision structure — use them for clarity first, but they're rarely a performance regression over hand-written checks.
Switch expressions over if/else chains
Prefer a switch expression when you're producing a value from a set of mutually exclusive conditions:
// ❌ chain of ifs, easy to miss a case, no exhaustiveness helpstring Describe(Shape s){if (s is Circle c) return $"circle r={c.Radius}";if (s is Square sq) return $"square side={sq.Side}";else return "unknown";}// ✅ switch expressionstring Describe(Shape s) => s switch{Circle { Radius: var r } => $"circle r={r}",Square { Side: var side } => $"square side={side}",_ => "unknown",};
Keep switch statements (not expressions) when arms need multiple statements or side effects — don't force a switch expression with { ... } block bodies just to avoid if.
Pattern kinds
Property patterns — match on member values without a temp variable:
if (order is { Status: OrderStatus.Shipped, Total: > 0 }){// ...}
Positional patterns — for types with Deconstruct (records get this free):
record Point(int X, int Y);string Quadrant(Point p) => p switch{(0, 0) => "origin",( > 0, > 0) => "Q1",( < 0, > 0) => "Q2",( < 0, < 0) => "Q3",( > 0, < 0) => "Q4",_ => "axis",};
Relational patterns — >, <, >=, <= against a constant, combined with and/or:
string Grade(int score) => score switch{>= 90 and <= 100 => "A",>= 80 and < 90 => "B",>= 70 and < 80 => "C",_ => "F",};
Logical patterns — and, or, not compose any patterns, including types:
if (value is not null and not "") { /* non-empty */ }if (shape is Circle or Square) { /* ... */ }
Type patterns — x is Foo f tests and casts in one step; prefer this over as + null-check (see Performance below).
List patterns (C# 11+) — match array/list shape and slice the rest:
int[] arr = [1, 2, 3, 4, 5];if (arr is [var first, .., var last]){Console.WriteLine($"{first}..{last}"); // 1..5}if (arr is [var head, .. var rest]){Console.WriteLine(rest.Length); // 4}if (arr is [1, 2, ..]) { /* starts with 1, 2 */ }if (arr is []) { /* empty */ }
Works on any type with an indexer and Length/Count, or that's countable/indexable per the pattern spec (arrays, List<T>, Span<T>, strings).
is not null
Prefer x is not null / x is null over != null / == null — it reads better next to other patterns and can't be redirected by an overloaded ==/!= on the type (patterns always use the real identity/type check).
if (result is not null){Use(result);}
when guards
Add extra conditions a pattern can't express directly. Keep the pattern doing the shape-matching and the guard doing the logic:
string Classify(Exception ex) => ex switch{HttpRequestException { StatusCode: var code } when code is >= System.Net.HttpStatusCode.InternalServerError=> "server error",HttpRequestException => "client error",TimeoutException => "timeout",_ => "unknown",};
Exhaustiveness — real for types, not for enums
The compiler warns (CS8509/CS8846) when a switch expression doesn't cover every case it can statically prove, and that's genuinely useful for sealed hierarchies and closed type sets — add a case, get a compile warning everywhere it's missing.
Enums are not exhaustive in any meaningful sense. An enum is backed by its underlying integer type — nothing stops a caller passing (DayKind)99. The compiler will treat a switch over every named enum member as "exhaustive" and let you skip _, but that's a false sense of safety: an out-of-range value falls through to default (statement) or throws SwitchExpressionException (expression) at runtime, not compile time.
// Always include a fallback arm for enums, even if you've listed every memberstring Describe(DayKind k) => k switch{DayKind.Weekday => "weekday",DayKind.Weekend => "weekend",_ => throw new ArgumentOutOfRangeException(nameof(k)),};
For real exhaustiveness, use a sealed base class / interface with a closed set of implementations (or a discriminated union library) — the compiler's exhaustiveness check is meaningful there, so let it do the work instead of adding default: throw:
// Base is sealed's contract enforced via permitted derived set — compiler flags missing armsabstract record Shape;sealed record Circle(double Radius) : Shape;sealed record Square(double Side) : Shape;double Area(Shape s) => s switch{Circle c => Math.PI * c.Radius * c.Radius,Square sq => sq.Side * sq.Side,// no `_` needed — compiler proves these two cover every non-null Shape;// omitting one produces a warning here, not a runtime surprise};
.NET 11 / C# 15 — closed hierarchies and unions give real exhaustiveness
Check the project's TargetFramework/LangVersion first (see csharp-language-versions) — these need net11.0+ (or explicit LangVersion=15) and are unavailable on net10.0 and earlier. Don't retarget or raise LangVersion just to use them.
Unlike the sealed-hierarchy pattern above (which is exhaustive only because you enforced the closed set by convention), closed and union make the compiler enforce it:
// closed record hierarchy — derived types restricted to the declaring assemblypublic closed record class GateState;public sealed record Open : GateState;public sealed record Closed : GateState;// no `_` arm — and unlike a sealed base, an external assembly literally cannot add a casestring Describe(GateState g) => g switch{Open => "open",Closed => "closed",};
A switch over a closed type or a union warns (CS8509) on a missing case exactly like the sealed-hierarchy example — the difference is that a sealed base only stops external derivation by convention within your own codebase's discipline, while closed is a compiler-enforced assembly boundary: nothing outside the declaring assembly can add a case, ever. closed implies abstract — you can't instantiate GateState directly, and closed can't combine with sealed/static/abstract. It's also not transitive: an intermediate type in the hierarchy needs its own closed if you want its subtree closed too.
Union types (union) declare the exact case set inline instead of via inheritance, with implicit conversions from each case type:
public union Pet(Cat, Dog, Bird);public record Cat(string Name);public record Dog(string Name);public record Bird(string Name);Pet p = new Cat("Tom"); // implicit conversion from case typestring Describe(Pet pet) => pet switch{Cat c => $"cat {c.Name}",Dog d => $"dog {d.Name}",Bird b => $"bird {b.Name}", // omit any arm here and the compiler warns (CS8509)};
Prefer union when the case types are otherwise unrelated (no shared base makes sense) and you just need "exactly one of these shapes." Prefer a closed hierarchy when the cases share real inherited members/behavior. See csharp-modern-types for the full decision between union, closed, and a classic abstract base.
Some parts of the union spec aren't implemented in preview/RC compilers — verify a given union pattern compiles before relying on it in a codebase tracking .NET 11 previews/RC.
Any type with a Deconstruct method (or a record, which gets one automatically) can be pattern-matched positionally or deconstructed into locals:
var (x, y) = point; // deconstruction assignmentif (point is (0, 0)) { } // positional pattern
When a pattern helps vs when it hurts
Use a pattern when it replaces a cast, a null check, and a conditional in one readable expression — that's the sweet spot. Stop when the pattern grows past what a glance can parse:
// ✅ clear — one shape, one meaningif (response is { StatusCode: 200, Body: not null } r) { ... }// ❌ unreadable — deeply nested property/positional/logical patternsif (response is { Headers: { ["X-Trace"]: [var traceId, ..] }, Body: (var a, (var b, not null and > 0)) }) { ... }
If a pattern needs a comment to explain what it matches, extract a method or a few ifs instead. A named helper (IsRetryable(ex)) often communicates intent better than a when guard buried in a switch arm.
Performance notes
- The compiler lowers pattern matching (especially multi-arm
switchon type/shape) into an efficient decision structure — it doesn't re-test earlier conditions redundantly the way a naiveif/else ifchain sometimes does when hand-written carelessly. You don't need to hand-optimize arm order for performance; order for readability and to put more specific patterns before general ones (required for correctness anyway). x is Foo f(type pattern) andx as Foodo the same runtime type check, but the type pattern also handles the null check and scoping in one operation and cannot be forgotten — prefer it.asis instead appropriate when you need a nullable result that isn't tested immediately.- Pattern matching doesn't allocate beyond what the equivalent hand-written check would; list patterns on
Span<T>/arrays slice without copying when the sub-pattern doesn't bind the remainder, and bind a genuineT[]/Span<T>slice (which may allocate for arrays) when it does (.. var rest).
Checklist
- [ ] Value-producing branches use a
switchexpression, not anif/else ifchain - [ ]
is not null/is nullinstead of!= null/== null - [ ] Type patterns (
is Foo f) instead ofas+ null check when you're about to use the cast - [ ] Closed hierarchies are
sealed/exhaustive by design so the compiler's exhaustiveness check is meaningful - [ ] On
net11.0+/C# 15:closed/unionused (not retrofitted onto older TFMs) where the compiler should enforce exhaustiveness across assembly boundaries - [ ]
switchover anenumstill has a_/defaultfallback — enum exhaustiveness is not real - [ ]
whenguards carry logic, not shape-matching that a pattern could express - [ ] No pattern so deep it needs a comment to explain — extract a method instead