← Back to Blog

Pattern Matching Enhancements: Property, List, and Relational Patterns

Pattern matching in C# has been one of the most significant evolutions in the language over the past few versions. It allows you to express complex conditional logic declaratively, reducing boilerplate and making your intentions crystal clear. What started in C# 7 with simple type checks has matured into an expressive toolkit in C# 8, 9, 10, and beyond: property patterns, list patterns, relational patterns, and combinations of them all.

Unlike traditional if-else chains, pattern matching encourages you to think about the structure of your data rather than imperatively checking every condition. It feels almost functional in nature, yet seamlessly integrates with the object-oriented C# you already know. By the end of this article, you'll see not just the syntax but also how these patterns influence the way you design and reason about your code.

Property Patterns: Matching Object Structure

Property patterns allow you to check values on an object's properties inline. Imagine a scenario where you need to handle different types of orders based on status, customer type, and amount. Traditionally, you'd end up with nested if-else statements that quickly become hard to read.

Property patterns simplify this by letting you write conditions that feel declarative and readable. You can match multiple properties at once, including nested properties, which is incredibly powerful for domain-driven design.

public class Order
{
    public string Status { get; set; }
    public decimal Amount { get; set; }
    public Customer Customer { get; set; }
}

public class Customer
{
    public string Name { get; set; }
}

public string GetOrderDescription(Order order) => order switch
{
    { Status: "Completed", Amount: > 1000 } => "High-value completed order",
    { Status: "Pending", Customer.Name: "VIP" } => "VIP pending order",
    { Status: "Cancelled" } => "Cancelled order",
    _ => "Regular order"
};

Here, the first case matches completed orders with amounts over 1000. The second checks pending orders from VIP customers. Instead of several nested conditions, everything is expressed in a single, readable block. You can even combine property patterns with relational patterns for maximum expressiveness.

List Patterns: Matching Collections

List patterns take pattern matching into the realm of collections. They allow you to match arrays, lists, or spans based on their shape and content. This is particularly useful when parsing, validating, or processing data structures where the order or presence of elements matters.

public string DescribeNumbers(int[] numbers) => numbers switch
{
    [] => "Empty array",
    [var single] => $"Single element: {single}",
    [var first, var second] => $"Two elements: {first}, {second}",
    [var first, .., var last] => $"Many elements, first: {first}, last: {last}",
    _ => "Too many elements"
};

// Specific pattern matching
public bool IsFibonacciStart(int[] sequence) => sequence switch
{
    [0, 1] or [1, 1] => true,
    _ => false
};

The .. syntax acts like a wildcard for elements in the middle of the collection. Combined with named variables for specific positions, you can create patterns that describe not just values but also the structure of your data.

List patterns shine in scenarios like parsing command-line arguments, validating input arrays, or processing time-series data where the first and last elements carry special meaning.

Relational Patterns: Expressing Value Comparisons

Relational patterns make numeric comparisons declarative. Instead of writing nested if statements for ranges, you can now write them inline and clearly.

public string GetTemperatureDescription(double temperature) => temperature switch
{
    < 0 => "Freezing",
    >= 0 and < 10 => "Cold",
    >= 10 and < 20 => "Cool",
    >= 20 and < 30 => "Warm",
    >= 30 => "Hot"
};

public string ClassifyScore(int score) => score switch
{
    >= 90 => "Excellent",
    >= 80 => "Good",
    >= 70 => "Satisfactory",
    >= 60 => "Pass",
    < 60 => "Fail"
};

Notice how the code reads almost like natural language. Relational patterns make boundaries and ranges explicit, reducing ambiguity and increasing readability. When combined with property patterns, you can write very expressive rules for objects with numerical properties.

Combining Patterns: Unlocking Full Expressiveness

The real power emerges when you combine property, list, relational, and type patterns. C# allows you to compose patterns to express complex conditions concisely. This often replaces deeply nested conditionals with elegant, maintainable code.

public record WeatherData(string Location, double Temperature, string Condition);

public string GetWeatherAdvice(WeatherData weather) => weather switch
{
    { Location: "Beach", Temperature: >= 30, Condition: "Sunny" } => "Perfect beach day!",
    { Temperature: < 0 } => "Stay indoors, it's freezing!",
    { Condition: "Rainy", Temperature: >= 15 and <= 25 } => "Nice weather for a walk in the rain",
    { Location: var loc, Temperature: var temp } when temp > 35 => $"Extreme heat warning for {loc}",
    _ => "Check the forecast"
};

In this example, the first case matches hot, sunny beach weather. The next handles freezing temperatures globally. The third combines a relational pattern with a property pattern for rainy weather. The fourth uses a when clause for additional conditions. This approach is more readable than multiple nested if statements and encourages thinking in terms of patterns rather than steps.

Advanced Scenarios: Validation and Parsing

Beyond simple matching, patterns are powerful for validating data structures or parsing configuration objects. Type patterns combined with property and relational patterns make your code safer and less error-prone.

public record Config(string Type, object Value);

public bool ValidateConfig(Config config) => config switch
{
    { Type: "Port", Value: int port } when port is >= 1024 and <= 65535 => true,
    { Type: "Host", Value: string host } when !string.IsNullOrEmpty(host) => true,
    { Type: "Timeout", Value: TimeSpan ts } when ts > TimeSpan.Zero => true,
    _ => false
};

Here, each pattern ensures the value matches both type and constraints. This eliminates the need for manual casting and extensive null checks. It’s a clear example of making invalid states unrepresentable, a core principle in building robust systems.

Performance and Best Practices

Pattern matching is not just syntactic sugar. The compiler often optimizes switch expressions into jump tables, making them faster than equivalent if-else chains for certain scenarios. That said, excessive or overly complex patterns can harm readability, so balance clarity with expressiveness.

Best practices include:

  • Use exhaustive switch expressions with _ to handle all cases.
  • Prefer switch expressions over statements for concise code.
  • Combine patterns thoughtfully, avoiding overly nested conditions.
  • Use var patterns when you don’t care about the exact value.
  • Complement pattern matching with records and immutable types for maximum safety.

Migration from Traditional Code

For those working with older C# codebases, migrating is straightforward. Replace if-else chains with declarative switch expressions. Instead of:

if (obj is string s && s.Length > 5)
{
    ...
}

You can write:

if (obj is string { Length: > 5 })
{
    ...
}

This approach is less error-prone, easier to read, and more maintainable, especially when scaling to multiple conditions.

Summary

Advanced pattern matching in C# transforms the way we handle conditional logic. Property patterns let you match object structures, list patterns work elegantly with collections, and relational patterns make value comparisons declarative. When combined, these patterns reduce boilerplate, make your code self-documenting, and prevent subtle bugs.

More importantly, pattern matching encourages thinking in terms of **data shapes and rules** rather than step-by-step conditions. It changes how you design classes, records, and data flows, making your software easier to reason about. Start small, experiment with combinations, and gradually refactor existing logic-you'll soon notice your code becomes cleaner, safer, and more expressive.