C# Pattern Matching: Syntax and Practical Use
c# pattern matching: Explore C# pattern matching with practical examples: type, property, and positional patterns, switch expressions, and when to use them effectively.
C# pattern matching has evolved from a simple is type check into a full-featured expression syntax. If you have written code like if (obj is string s) or used a switch on a type, you have already used pattern matching. The question is how far you can push it without making the code harder to read. This article covers the core pattern kinds, how they combine, and where they each fit in real code.
Core Pattern Kinds You Will Use Daily
Pattern matching in C# revolves around a handful of pattern categories. The most common are the type pattern, property pattern, positional pattern, and the var pattern. You also have logical patterns like and, or, and not that combine simpler patterns.
A type pattern checks the runtime type of an input and, when it matches, introduces a variable of that type.
if (input is int number) { Console.WriteLine(number * 2); }
Here number is only available inside the if body and holds the value of input if input was an int. This is a direct replacement for the older is cast check:
var number = input as int?; if (number.HasValue) { Console.WriteLine(number.Value * 2); }
The pattern version is shorter and also avoids the nullable wrapper.
Property patterns let you match on properties of an object without writing multiple conditional statements. For example, you can check a point's coordinates:
if (point is { X: 0, Y: 0 }) { Console.WriteLine("Origin"); }
That is equivalent to the explicit check but easier to read, especially when the property list grows.
Positional patterns work with types that have a Deconstruct method. The classic example is a Point that deconstructs into X and Y:
public record Point(int X, int Y); if (point is (0, 0)) { Console.WriteLine("Origin"); }
The record type automatically provides Deconstruct, which makes the positional match possible.
The var pattern is less common but useful when you want to capture the value and apply a condition afterwards:
if (input is var result && result != null) { // use result }
However, this pattern is rarely necessary. You can usually just use the variable directly after a type check.
Switch Expressions: Turning Long if-else Chains into Data
Switch expressions were introduced in C# 8 and are a natural evolution of the switch statement. They return a value, which means you can assign the result directly to a variable. Consider an old-style switch on a shape type:
string Describe(Shape shape) { switch (shape) { case Circle c: return $"Circle with radius {c.Radius}"; case Rectangle r: return $"Rectangle {r.Width}x{r.Height}"; default: return "Unknown"; } }
The equivalent switch expression is more compact:
string Describe(Shape shape) => shape switch { Circle c => $"Circle with radius {c.Radius}", Rectangle r => $"Rectangle {r.Width}x{r.Height}", _ => "Unknown" };
Notice the _ discard pattern replaces default. This is a clean way to handle a fallback case.
Switch expressions evaluate arms in order, and the first match wins. There is no fall-through between arms. The compiler also enforces exhaustiveness for certain input types, but for a general Shape type you must provide an explicit _ arm unless the compiler can prove all cases are covered.
Combining Patterns with and, or, not
Logical patterns allow you to build complex conditions without nested if statements. The and pattern requires both sides to match, or requires either side, and not negates the pattern.
if (point is { X: > 0, Y: > 0 } and { X: < 100, Y: < 100 }) { Console.WriteLine("Inside the box"); }
You could also use relational patterns directly:
if (point is { X: > 0 and < 100, Y: > 0 and < 100 }) { Console.WriteLine("Inside the box"); }
The relational patterns are part of the pattern grammar and work with numeric types.
A practical use of not is to check for a null or a specific case:
if (input is not null) { // handle non-null }
This is a modern alternative to if (input != null). It reads well in guards.
Recursive Patterns: Nested and Composed Matches
Patterns can be nested. The recursive nature of C# pattern matching lets you combine property, positional, and logical patterns in one expression. For example, you can match a Customer that has an Address with a specific City:
if (customer is Customer { Address: { City: "London" } }) { // customer lives in London }
You can also use and inside property patterns to require multiple conditions on nested properties:
if (customer is Customer { Address: { City: "London", Street: "Baker" } }) { // living on Baker Street in London }
Positional patterns can also be nested. If you have a Point inside a Shape, you can write:
if (shape is Rectangle { TopLeft: (0, 0) }) { // rectangle starts at origin }
Recursive patterns reduce the need for multi-line if blocks that first cast and then check properties. They also make the structure of the match visible in one place.
When Pattern Matching Costs More Than It Saves
Pattern matching is expressive, but it is not always the most maintainable choice. The patterns you write are evaluated at runtime, and the compiler may generate temporary variables and type checks under the hood. For most business code, that cost is negligible. However, if you are matching on a hot path that processes millions of objects per second, the overhead of recursive patterns can exceed the cost of a simple hand-written condition.
The exact performance depends on the pattern complexity, the number of arms in a switch expression, and the runtime version. The compiler can optimize simple type patterns to isinst checks. Complex recursive or property patterns may require multiple method calls or allocations. You should profile before optimizing. If you see a measurable difference between a pattern-based implementation and a manual if chain, then the manual version might be justified. In typical application code, readability wins.
Another maintainability concern is that overusing patterns can make the intent obscure. A pattern like { A: { B: >= 10 and <= 20 } } is compact but also harder to read than a well-named method that checks the same condition. If the condition is reused or has a business meaning, extract it into a method with a clear name.
Version Compatibility and Language Constraints
Pattern matching features have been introduced gradually across C# versions. The is type pattern and simple property patterns have been available since C# 7. Switch expressions arrived in C# 8. Logical patterns (and, or, not) and relational patterns (>, <) came in C# 9. The not pattern is also from C# 9.
If you are working on a legacy codebase that still targets an older language version, certain patterns may not compile. For example, an and pattern will fail on C# 8 and earlier. The language version is determined by the target framework and the <LangVersion> setting in the project file. Before you introduce a pattern, confirm that your target framework and language version support it.
A common source of confusion is the difference between a switch expression and a switch statement. A switch expression uses the switch keyword after the input, and its arms are separated by commas. A switch statement uses the traditional case labels. You can mix patterns with both, but switch expressions are the more natural fit for returning a value.
Debugging Pattern Matches: What the Compiler Actually Sees
When a pattern does not match as you expect, the reason is often a subtle mismatch between the type you assume and the runtime type. For example, a type pattern like input is int number will not match if input is a long even if the value is small. The type must be exactly the same or a subtype. Similarly, a property pattern like { X: 0 } will be true only if X is exactly 0. If X is a nullable integer with value 0, the pattern { X: 0 } will not match because the property type is int?, and the pattern expects an int. You need to handle the nullable case explicitly:
if (point is { X: int x })
That version matches only if X is non-null and assigns the unwrapped value to x.
Another common issue is that a positional pattern requires the type to have a Deconstruct method. If you try to use point is (0, 0) on a class that does not implement Deconstruct, the compiler will produce an error. Records are the simplest way to get the behavior because they supply Deconstruct automatically.
When debugging, check the exact runtime type and the nullable state of the properties. Use the debugger to inspect the input before the pattern match. If the pattern is complex, break it into smaller pieces and test each part.
Choosing the Right Kind of Pattern for the Situation
Different patterns suit different scenarios. Type patterns are the right choice when you need to dispatch on the runtime type of an object. Switch expressions are ideal when you have multiple types or values and need a single output. Property patterns work well when the logic depends on property values of a known type. Positional patterns are convenient when the type has a meaningful deconstruction into coordinates or similar pairs.
Use logical patterns when you need to combine conditions that are otherwise separate. Avoid creating deeply nested patterns that span more than a few levels. If you find yourself writing { A: { B: { C: ... } } }, define an intermediate variable or a helper method to keep the logic readable.
In many cases, a simple if statement is clearer than a pattern. For example, checking if (age >= 18) is more readable than if (age is >= 18). Pattern matching shines when the condition is naturally expressed as a shape: a type, a property combination, or a structured decomposition. Use it when it reduces visual noise, not when it adds a second layer of syntax to a simple check.
A final note on null handling: a property pattern like { X: 0 } will not match if the input is null. The pattern requires a non-null object. If you want to include null in the logic, add a separate null check or use the not null pattern. This behavior is consistent with the runtime semantics of accessing a property on a null reference, which would throw an exception in normal code. Pattern matching avoids the exception by simply not matching, which is often the safer behavior.
Pattern matching is a mature feature in modern C#. When used with discipline, it replaces repetitive type checks and nested conditionals with a compact, declarative form. The tradeoff is that an over-reliance on complex patterns can obscure the logic. Use the simplest pattern that communicates the intent, and remember that the rest of the team has to read the code too.