22 May 2025

Pattern Matching in C#

Modern C# has undergone a quiet revolution in how we handle conditional logic. Gone are the days when type checks and value comparisons required verbose if-else cascades or clumsy switch statements. The introduction of sophisticated pattern matching capabilities, particularly since C# 8.0, has fundamentally changed how developers write control flow - making code simultaneously more expressive, more concise, and safer.

Enhancing Control Flow with C# Pattern Matching

Pattern matching in C# offers a concise syntax for testing an expression and performing actions when that expression matches a specific pattern. It enables developers to test values against various criteria, including their type, value, properties, or even the structure of complex objects. This capability is primarily exposed through the is expression and the switch expression (or switch statement). While basic type checking existed earlier, C# 8.0 introduced a richer vocabulary of patterns, significantly extending its utility and impact on everyday coding practices. This enhancement is crucial for effective c# switch pattern matching and c# if pattern matching, transforming verbose conditional logic into compact, understandable constructs.

Let's explore some of the key pattern types available and how they simplify common programming tasks, providing clear c# pattern matching examples that illustrate their benefits.

Practical C# Pattern Matching Examples

The true power of pattern matching becomes evident when contrasting it with traditional approaches. Developers gain significant improvements in code readability and conciseness.

Declaration Pattern

The declaration pattern checks an expression's run-time type and, if it matches, assigns the result to a new variable. This eliminates the need for explicit casting and null checks in many scenarios.

Before:

public void ProcessAssetOld(object asset)
{
    if (asset is SoftwareLicense)
    {
        SoftwareLicense license = (SoftwareLicense)asset;
        Console.WriteLine($"Software License: {license.ProductName}, Expiration: {license.ExpirationDate.ToShortDateString()}");
    }
    else if (asset is HardwareComponent)
    {
        HardwareComponent hardware = (HardwareComponent)asset;
        Console.WriteLine($"Hardware Component: {hardware.ComponentName}, Serial: {hardware.SerialNumber}");
    }
    else
    {
        Console.WriteLine("Unknown asset type.");
    }
}

public class SoftwareLicense { public string ProductName { get; set; } public DateTime ExpirationDate { get; set; } }
public class HardwareComponent { public string ComponentName { get; set; } public string SerialNumber { get; set; } }

After (using Declaration Pattern):

public void ProcessAssetNew(object asset)
{
    if (asset is SoftwareLicense license)
    {
        Console.WriteLine($"Software License: {license.ProductName}, Expiration: {license.ExpirationDate.ToShortDateString()}");
    }
    else if (asset is HardwareComponent hardware)
    {
        Console.WriteLine($"Hardware Component: {hardware.ComponentName}, Serial: {hardware.SerialNumber}");
    }
    else
    {
        Console.WriteLine("Unknown asset type.");
    }
}

The license and hardware variables are only in scope and assigned if the pattern matches, enhancing type safety and reducing potential runtime errors.

Type Pattern

Similar to the declaration pattern, the type pattern checks an expression's run-time type. It's often used within switch expressions where a new variable declaration is not strictly necessary for the pattern itself.

Before:

public decimal CalculateShippingCostOld(Shipment shipment)
{
    if (shipment == null)
        throw new ArgumentNullException(nameof(shipment));
    if (shipment.GetType() == typeof(StandardShipment))
        return 5.00m;
    if (shipment.GetType() == typeof(ExpressShipment))
        return 15.00m;
    if (shipment.GetType() == typeof(InternationalShipment))
        return 25.00m;
    return 0.00m; // Default for unknown types
}

public abstract class Shipment { }
public class StandardShipment : Shipment { }
public class ExpressShipment : Shipment { }
public class InternationalShipment : Shipment { }

After (using Type Pattern in switch expression):

public decimal CalculateShippingCostNew(Shipment shipment) => shipment switch
{
    StandardShipment => 5.00m,
    ExpressShipment => 15.00m,
    InternationalShipment => 25.00m,
    null => throw new ArgumentNullException(nameof(shipment)),
    _ => 0.00m // Handle unknown shipment types
};

This demonstrates concise c# switch case pattern matching for different types.

Constant Pattern

A constant pattern tests whether an expression's result equals a specified constant value. This simplifies discrete value comparisons.

Before:

public string GetOrderStateOld(OrderStatus status)
{
    if (status == OrderStatus.Pending)
        return "Order is awaiting processing.";
    else if (status == OrderStatus.Shipped)
        return "Order has been dispatched.";
    else if (status == OrderStatus.Delivered)
        return "Order has been delivered.";
    else if (status == OrderStatus.Cancelled)
        return "Order has been cancelled.";
    else
        return "Unknown order state.";
}

public enum OrderStatus { Pending, Shipped, Delivered, Cancelled, Returned }

After (using Constant Pattern):

public string GetOrderStateNew(OrderStatus status) => status switch
{
    OrderStatus.Pending => "Order is awaiting processing.",
    OrderStatus.Shipped => "Order has been dispatched.",
    OrderStatus.Delivered => "Order has been delivered.",
    OrderStatus.Cancelled => "Order has been cancelled.",
    _ => "Unknown order state."
};

This is a clean way to handle specific c# string pattern matching or enum values.

Relational Patterns

Relational patterns compare an expression's result with a constant using comparison operators (<, >, <=, >=). This makes range checks much more readable.

Before:

public string GetEmployeePerformanceLevelOld(int score)
{
    if (score < 50)
        return "Needs Improvement";
    else if (score >= 50 && score < 70)
        return "Meets Expectations";
    else if (score >= 70 && score < 90)
        return "Exceeds Expectations";
    else if (score >= 90)
        return "Outstanding";
    else
        return "Invalid Score"; // Should not happen with int, but for completeness
}

After (using Relational Patterns):

public string GetEmployeePerformanceLevelNew(int score) => score switch
{
    < 50 => "Needs Improvement",
    >= 50 and < 70 => "Meets Expectations",
    >= 70 and < 90 => "Exceeds Expectations",
    >= 90 => "Outstanding",
    _ => "Invalid Score" // Catches any unexpected integer values
};

This shows the power of combining relational patterns with logical patterns for pattern matching c#.

Property Pattern

The property pattern allows matching an expression's properties or fields against nested patterns. This is incredibly useful for inspecting the state of objects.

Before:

public decimal CalculateOrderDiscountOld(CustomerOrder order)
{
    if (order == null) return 0m;
    if (order.TotalAmount >= 500 && order.Customer.IsPremium)
    {
        return 0.15m; // 15% for premium customers with large orders
    }
    else if (order.TotalAmount >= 100)
    {
        return 0.05m; // 5% for general large orders
    }
    return 0m;
}

public class Customer { public bool IsPremium { get; set; } }
public class CustomerOrder { public decimal TotalAmount { get; set; } public Customer Customer { get; set; } }

After (using Property Pattern):

public decimal CalculateOrderDiscountNew(CustomerOrder order) => order switch
{
    { TotalAmount: >= 500, Customer.IsPremium: true } => 0.15m,
    { TotalAmount: >= 100 } => 0.05m,
    null => 0m,
    _ => 0m
};

This demonstrates powerful c# property pattern matching, simplifying object state checks.

Positional Pattern

Positional patterns deconstruct an expression's result (like tuples or records with Deconstruct methods) and match the resulting values against nested patterns.

Before:

public string DescribeTransactionOld(Transaction transaction)
{
    if (transaction.Type == TransactionType.Deposit && transaction.Amount > 1000)
        return "Large Deposit";
    else if (transaction.Type == TransactionType.Withdrawal && transaction.Amount > 500)
        return "Significant Withdrawal";
    else if (transaction.Type == TransactionType.Fee && transaction.Amount > 0)
        return "Applied Fee";
    else
        return "Standard Transaction";
}

public record Transaction(TransactionType Type, decimal Amount);
public enum TransactionType { Deposit, Withdrawal, Fee, Transfer }

After (using Positional Pattern):

public string DescribeTransactionNew(Transaction transaction) => transaction switch
{
    (TransactionType.Deposit, > 1000m) => "Large Deposit",
    (TransactionType.Withdrawal, > 500m) => "Significant Withdrawal",
    (TransactionType.Fee, > 0m) => "Applied Fee",
    _ => "Standard Transaction"
};

This concise form enhances pattern matching c# readability for deconstructable types.

Logical Patterns (and, or, not)

Logical patterns combine other patterns using and, or, and not keywords, enabling complex conditional checks within a single pattern.

Before:

public bool CanGrantAccessOld(UserRole role, int securityLevel, bool hasTwoFactorAuth)
{
    if ((role == UserRole.Administrator || role == UserRole.Manager) && securityLevel > 7 && hasTwoFactorAuth)
        return true;
    else if (role == UserRole.Developer && securityLevel > 5)
        return true;
    else if (role != UserRole.Guest && securityLevel >= 3 && securityLevel <= 10)
        return true;
    else
        return false;
}

public enum UserRole { Guest, User, Developer, Manager, Administrator }

After (using Logical Patterns):

public bool CanGrantAccessNew(UserRole role, int securityLevel, bool hasTwoFactorAuth) => (role, securityLevel, hasTwoFactorAuth) switch
{
    (UserRole.Administrator or UserRole.Manager, > 7, true) => true,
    (UserRole.Developer, > 5, _) => true,
    (not UserRole.Guest, >= 3 and <= 10, _) => true,
    _ => false
};

The c# is pattern matching combined with logical operators becomes very expressive.

Var Pattern

The var pattern always matches an expression and assigns its result to a new local variable. It is particularly useful when you need to capture the entire input value of a switch expression (or a part of a complex pattern) to perform additional, more complex checks within a when clause, especially those involving method calls or properties that cannot be expressed by other patterns directly.

Before:

using System.Collections.Generic;
using System.Linq;

public string AnalyzeNumberSequenceOld(IEnumerable<int> numbers)
{
    if (numbers == null)
        return "Invalid sequence (null)";
    if (numbers.Count() == 0)
        return "Empty sequence";
    if (numbers.Count() > 5 && numbers.Average() > 10.0)
        return "Long sequence with high average";
    return "Normal sequence";
}

After (using Var Pattern with when clause):

using System.Collections.Generic;
using System.Linq;

public string AnalyzeNumberSequenceNew(IEnumerable<int> numbers) => numbers switch
{
    null => "Invalid sequence (null)", 
    var list when list.Count() == 0 => "Empty sequence",
    var list when list.Count() > 5 && list.Average() > 10.0 => "Long sequence with high average",
    _ => "Normal sequence"
};

Here, var list captures the IEnumerable<int> instance. This allows subsequent complex LINQ queries (like Count() and Average()) to be performed directly on the list variable within the when clause, without needing explicit type checks using is.

List Patterns (C# 11)

Introduced in C# 11, list patterns allow matching against elements in a sequence (like arrays or List<T>). They are incredibly powerful for validating the structure and content of collections.

Before:

public bool ValidateLogEntryOld(string[] logParts)
{
    if (logParts == null || logParts.Length < 2) return false;

    if (logParts[0] == "ERROR" && logParts.Length >= 3 && logParts[1] == "AUTH" && logParts[2] == "FAILED")
        return true;
    if (logParts[0] == "INFO" && logParts.Length >= 2 && logParts[1] == "CONNECTED")
        return true;
    if (logParts[0] == "WARN" && logParts.Length >= 4 && logParts[1] == "DISK" && logParts[2] == "SPACE" && int.TryParse(logParts[3], out int space) && space < 10)
        return true;
    return false;
}

After (using List Patterns):

public bool ValidateLogEntryNew(string[] logParts) => logParts switch
{
    ["ERROR", "AUTH", "FAILED", ..] => true, // Error authentication failed
    ["INFO", "CONNECTED", ..] => true,     // Client connected
    ["WARN", "DISK", "SPACE", var size, ..] when int.TryParse(size, out int space) && space < 10 => true, // Low disk space
    _ => false // Any other log entry
};

This is a powerful feature for pattern matching against sequences, especially useful in scenarios like parsing command-line arguments or log entries with varying structures. The .. (slice pattern) matches zero or more elements, providing flexibility.

Beyond the Basics: Cleaner Code with Pattern Matching

The benefits of pattern matching extend beyond mere syntactical sugar. By adopting these features, developers inherently write code that is:

  • More Expressive: Patterns directly convey intent, making code easier to read and understand at a glance. Complex conditional logic becomes intuitive.
  • More Concise: Eliminating boilerplate if-else blocks and explicit casts reduces code volume, leading to less code to maintain and fewer opportunities for errors.
  • Safer: Switch expressions, in particular, often require exhaustive pattern matching. The C# compiler assists by warning developers if a switch expression does not cover all possible input values, helping prevent runtime exceptions. This compiler-enforced exhaustiveness improves the correctness of software.
  • Easier to Refactor: With cleaner, more modular conditional logic, refactoring becomes less daunting. Developers can modify or extend behavior with minimal impact on other parts of the system.

Pattern matching has transformed C# into a language that offers elegant solutions for complex data handling and control flow. From basic type checks to deconstructing objects and validating sequences, the capabilities provided by C# 8.0+ are essential tools for any modern software developer. Embracing these powerful features not only enhances code quality but also streamlines the development process, contributing to more reliable and maintainable applications. Developers still relying on older styles of if-else or switch statements can greatly benefit from incorporating these patterns, making their C# programs more robust and enjoyable to work with.

Related Articles