Pattern Matching Deep Dive in C#
Pattern matching has evolved from a simple curiosity into one of the most powerful features in C#. What started with basic type checks in C# 7 has grown into a rich system of patterns that can replace sprawling if-else chains with concise, readable code.
Type Patterns
The most fundamental pattern is the type pattern, introduced in C# 7. It combines a type check with a variable declaration:
object value = GetValue();
if (value is string text)
{
Console.WriteLine($"Got a string of length {text.Length}");
}
Before pattern matching, you would need a separate cast after the type check. The type pattern eliminates that redundancy entirely.
Property Patterns
C# 8 introduced property patterns, which let you match on the values of an object's properties. This is where pattern matching starts to feel genuinely transformative:
public static decimal CalculateDiscount(Order order) => order switch
{
{ Total: > 1000, Customer.IsPremium: true } => order.Total * 0.15m,
{ Total: > 500, Customer.IsPremium: true } => order.Total * 0.10m,
{ Total: > 1000 } => order.Total * 0.05m,
_ => 0m
};
Notice the nested property access with Customer.IsPremium — you can drill into nested objects without intermediate variables.
Relational Patterns
C# 9 added relational patterns using <, >, <=, and >= operators, along with combinators and, or, and not:
public static string ClassifyTemperature(double celsius) => celsius switch
{
< 0 => "Freezing",
>= 0 and < 15 => "Cold",
>= 15 and < 25 => "Comfortable",
>= 25 and < 35 => "Warm",
>= 35 => "Hot",
double.NaN => "Invalid reading"
};
The and and or combinators are logical operators that work at the pattern level, not the expression level. This distinction matters: x is > 0 and < 100 is quite different from x > 0 && x < 100 in terms of what the compiler guarantees.
List Patterns
C# 11 brought list patterns, which match against elements in a sequence:
int[] numbers = [1, 2, 3, 4, 5];
var result = numbers switch
{
[1, .., 5] => "Starts with 1, ends with 5",
[_, _, 3, ..] => "Third element is 3",
[var first, .. var rest] => $"First: {first}, remaining: {rest.Length}",
[] => "Empty array"
};
The .. is the slice pattern, which matches zero or more elements. When combined with a variable (.. var rest), it captures the remaining elements as a slice.
Combining Patterns for Real-World Use
Patterns truly shine when combined. Consider validating an API request:
public static ValidationResult Validate(CreateUserRequest request) => request switch
{
{ Name: null or { Length: 0 } }
=> ValidationResult.Error("Name is required"),
{ Name.Length: > 200 }
=> ValidationResult.Error("Name exceeds maximum length"),
{ Email: not null } when !IsValidEmail(request.Email)
=> ValidationResult.Error("Invalid email format"),
{ Age: < 0 or > 150 }
=> ValidationResult.Error("Age is out of range"),
_ => ValidationResult.Success()
};
Each arm reads almost like a specification. The when clause adds a guard for logic that cannot be expressed purely as a pattern.
Performance Considerations
The compiler translates pattern matching into efficient code. A switch expression over patterns often compiles down to the same IL as hand-written if-else chains or switch statements — sometimes more efficiently, because the compiler can reorder checks and eliminate redundant comparisons.
However, property patterns do access properties, so if a property getter has side effects or is expensive to compute, be aware that it may be called during matching. In practice, this is rarely an issue since well-designed properties are lightweight.
When to Reach for Pattern Matching
Pattern matching is ideal when you have multiple conditions that depend on the shape or state of your data. It excels at:
- Replacing type-checking cascades with clear, flat structures
- Expressing business rules as declarative specifications
- Deconstructing objects without manual property access
- Validating inputs with readable, maintainable code
If you find yourself writing nested if statements that check types, null values, and property ranges, a switch expression with patterns will almost certainly be clearer. The compiler will also warn you about unhandled cases, giving you an extra layer of safety that traditional conditionals lack.
Pattern matching is not a replacement for polymorphism — if behaviour varies by type and you control the type hierarchy, virtual methods remain the right tool. But for external types, cross-cutting concerns, and ad-hoc decision logic, patterns are hard to beat.