22 May 2025
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.
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.
The true power of pattern matching becomes evident when contrasting it with traditional approaches. Developers gain significant improvements in code readability and conciseness.
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.
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.
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 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#
.
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 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.
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.
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
.
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.
The benefits of pattern matching
extend beyond mere syntactical sugar. By adopting these features, developers inherently write code that is:
if-else
blocks and explicit casts reduces code volume, leading to less code to maintain and fewer opportunities for errors.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.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.