Introduction
Nested if/else chains are a code smell that signals complexity hiding just below the surface. Modern pattern matching in C#: best practices explained will show you how to replace those chains with expressive, maintainable code that reads like business logic rather than control flow plumbing. We'll walk through switch expressions, property patterns, and list patterns—all tools that make your intent clearer and your code shorter.
Why pattern matching matters
Before C# 7, you had few options. You'd either nest conditionals until your brain hurt, or you'd write a series of guard clauses that felt brittle. Pattern matching changed that by giving you a declarative way to match structure and values. Instead of asking "what is this?", you're asking "does this fit this shape?"
This shift matters because:
- Business rules become readable expressions, not procedural spaghetti.
- The compiler helps you catch missing cases with exhaustiveness warnings.
- Refactoring is safer because the logic is localized and explicit.
- Junior developers see intent faster.
Switch expressions — Map inputs to outputs in one expression
Switch expressions (introduced in C# 8) replace the verbose switch statement with a concise expression. Instead of multiple case blocks and break statements, you write a compact mapping from pattern to result.
Consider an e-commerce order where we calculate shipping fees based on order properties:
public decimal CalculateShippingFee(Order order)
{
return order switch
{
{ Total: > 1000 } => 0m, // Free shipping over 1000
{ Country: "LK" } => 350m, // Flat rate for Sri Lanka
{ Country: "US" } => 15.99m, // Flat rate for US
{ Total: > 500 } => 5.99m, // Reduced for higher totals
_ => 9.99m // Default
};
}
Each arm fires on the first match. The underscore (_) is the fallback catch-all. No break statements, no accidental fall-through, and the logic is linear—exactly how a human would describe it.
You can also use switch expressions for null checks and type-based dispatch:
public string DescribePayment(Payment payment) =>
payment switch
{
null => "No payment provided",
CreditCard { IsExpired: true } => "Card expired",
CreditCard cc => $"Card ending in {cc.LastFour}",
BankTransfer bt => $"Bank: {bt.BankName}",
_ => "Unknown payment method"
};
This reads like a decision tree. Each line answers a question about what the payment is and what we care about. Traditional nested if statements would force you to write multiple levels of indentation and repeat payment checks.
Property patterns — Match on shape
Property patterns let you match objects based on their structure without extracting values first. You can check multiple properties at once, making complex business logic explicit.
Here's a real scenario from a SaaS billing system:
public class InvoiceProcessor
{
public ProcessingResult ProcessInvoice(Invoice invoice) =>
invoice switch
{
// High-value orders with payment received
{ Status: InvoiceStatus.Paid, Items.Count: > 5, Total: > 10000 }
=> new ProcessingResult(Priority.Urgent, "Priority fulfillment"),
// Paid but with items not yet shipped
{ Status: InvoiceStatus.Paid, ShippedDate: null }
=> new ProcessingResult(Priority.High, "Ready to ship"),
// Overdue unpaid invoices
{ Status: InvoiceStatus.Unpaid, DueDate: < DateTime.Now }
=> new ProcessingResult(Priority.Critical, "Send dunning notice"),
// Draft invoices (incomplete)
{ Status: InvoiceStatus.Draft }
=> new ProcessingResult(Priority.Low, "Awaiting finalization"),
_ => new ProcessingResult(Priority.Normal, "Standard processing")
};
}
Notice how readable this is: we're matching on invoice shape without explicit variable extraction. The pattern { Status: Paid, Items.Count: > 5, Total: > 10000 } says "match if status is Paid AND item count is greater than 5 AND total is more than 10000"—that's a compound condition expressed clearly.
You can nest property patterns too. If you have an order with a customer:
public string GetCustomerSegment(Order order) =>
order switch
{
{ Customer.IsVIP: true, Total: > 5000 } => "platinum",
{ Customer.IsVIP: true } => "gold",
{ Customer.AccountAgeDays: > 365, Total: > 1000 } => "loyal",
_ => "standard"
};
Each pattern is a mini-contract about what we expect the object to look like. If the order doesn't have those properties, it's a compile error—catch it before production.
List patterns — Match arrays and collections (C# 11)
List patterns, added in C# 11, let you match on array or list structure. You can destructure collections inline, making operations like "get the first and last items" or "check if a list starts with a specific value" trivial.
Imagine we're validating API request payloads:
public ValidationResult ValidateOrderItems(List<OrderItem> items) =>
items switch
{
[] => new ValidationResult(false, "Order must contain at least one item"),
[var first] => new ValidationResult(true, $"Single item order: {first.Sku}"),
[var first, .. var rest] when rest.Count > 100
=> new ValidationResult(false, "Order exceeds 101 items"),
[var first, .., var last] when first.Quantity > 1000 || last.Quantity > 1000
=> new ValidationResult(false, "Individual item quantity too high"),
[.., var last] when last.Discount > 0.5m
=> new ValidationResult(false, "Discount exceeds 50%"),
_ => new ValidationResult(true, "Order valid")
};
Breaking this down:
[]matches an empty list.[var first]matches a list with exactly one item.[var first, .. var rest]captures the first item and the rest as a separate list.[var first, .., var last]captures the first and last items, ignoring everything in between.[.., var last]captures just the last item.
You can combine list patterns with when guards (like the discount check above) for conditional logic. This is much cleaner than if (items.Count == 0) or if (items.Any() && items.First().Quantity > 1000).
Here's a practical trading example:
public TradeDecision AnalyzePriceMovement(decimal[] prices) =>
prices switch
{
[var p] => TradeDecision.Hold,
[var a, var b] when b > a * 1.05m => TradeDecision.Buy,
[var a, var b] when b < a * 0.95m => TradeDecision.Sell,
[.., var penultimate, var latest] when latest > penultimate * 1.1m
=> TradeDecision.SignalStrength,
_ => TradeDecision.Hold
};
With list patterns, you're explicitly handling different collection shapes and sizes. The code is your documentation—no need to explain what those array index checks mean.
Combining patterns for power
Real-world problems often require combining multiple pattern types. Here's a ride-hailing scenario:
public decimal CalculateSurgeMultiplier(Ride ride) =>
ride switch
{
// Premium passengers during peak hours get no surge
{ PassengerTier: PassengerTier.Premium, PickupTime.Hour: >= 8 and <= 9 }
=> 1.0m,
// Standard passengers during peak with high demand
{ PassengerTier: PassengerTier.Standard, PickupTime.Hour: >= 8 and <= 9,
AvailableDrivers: < 5 }
=> 2.5m,
// Evening off-peak: minimal surge
{ PickupTime.Hour: > 22 or < 6 } => 1.1m,
_ => 1.0m
};
Notice the and, or, and range patterns (>= 8 and <= 9). These combinators make complex conditions readable without nested parentheses.
Readable beats clever
The biggest mistake we see is treating pattern matching as a code golf challenge. Yes, you can nest complex patterns, but your future self will curse you.
Bad approach—too clever:
public decimal Fee(Order o) => o switch {
{ Total: > 1000, Country: "LK", Customer.IsVIP: true,
Items: [_, .., { Discount: > 0.3m }] } => 100m,
_ => 0m
};
Better approach—clear intent:
public decimal CalculateVIPDiscount(Order order)
{
var isQualifyingVip = order is
{
Customer.IsVIP: true,
Total: > 1000,
Country: "LK"
};
var hasHighDiscount = order.Items.Any(i => i.Discount > 0.3m);
return isQualifyingVip && hasHighDiscount ? 100m : 0m;
}
Or split into separate methods:
private bool IsEligibleForVIPDiscount(Order order) =>
order is { Customer.IsVIP: true, Total: > 1000, Country: "LK" };
private bool HasHighItemDiscount(Order order) =>
order.Items.Any(i => i.Discount > 0.3m);
public decimal CalculateFee(Order order) =>
IsEligibleForVIPDiscount(order) && HasHighItemDiscount(order)
? 100m
: 0m;
Each pattern arm should answer one business question. If you're adding comments to explain a pattern, it's too complex. Extract it to a helper method.
Common mistakes
Forgetting the discard pattern
Without the _ catch-all, the compiler warns that your switch isn't exhaustive. Always include it—even if you think you've covered every case. Production inputs surprise you.
// ❌ Compiler warning: not exhaustive
public string ProcessOrder(OrderStatus status) =>
status switch
{
OrderStatus.Pending => "Processing",
OrderStatus.Shipped => "In transit"
};
// ✅ Safe and explicit
public string ProcessOrder(OrderStatus status) =>
status switch
{
OrderStatus.Pending => "Processing",
OrderStatus.Shipped => "In transit",
_ => "Unknown status"
};
Over-nesting property patterns
If your pattern has more than two or three conditions, extract a helper. It's easier to test and read:
// ❌ Hard to read
public ProcessingResult Process(Invoice inv) =>
inv switch
{
{ Status: Paid, Items.Count: > 5, Total: > 10000,
Customer.IsVIP: true, ShippedDate: null,
DueDate: < DateTime.Now } => /* ... */,
_ => /* ... */
};
// ✅ Break it down
public ProcessingResult Process(Invoice inv) =>
inv switch
{
_ when IsHighValuePaidOrder(inv) && !IsShipped(inv)
=> HandleUrgentFulfillment(inv),
_ => HandleNormalFlow(inv)
};
private bool IsHighValuePaidOrder(Invoice inv) =>
inv is { Status: Paid, Items.Count: > 5, Total: > 10000, Customer.IsVIP: true };
Ignoring nullability with patterns
Patterns don't null-check automatically. Be explicit:
// ❌ Can throw NullReferenceException
public string Name(User u) =>
u switch
{
{ Profile.FirstName: "Admin" } => "Administrator",
_ => "User"
};
// ✅ Handle null cases
public string Name(User u) =>
u switch
{
null => "Unknown",
{ Profile: null } => "Profile missing",
{ Profile.FirstName: "Admin" } => "Administrator",
_ => "User"
};
Forgetting pattern order matters
Patterns are evaluated top to bottom. More specific patterns must come before general ones:
// ❌ General pattern catches everything first
public string Classify(object obj) =>
obj switch
{
object => "Some object", // Matches everything!
string s => "String", // Never reached
int i => "Integer", // Never reached
_ => "Unknown"
};
// ✅ Specific first, general last
public string Classify(object obj) =>
obj switch
{
string s => "String",
int i => "Integer",
object => "Some object",
_ => "Unknown"
};
Key takeaways
- Switch expressions replace verbose switch statements. Use them for any logic that maps input to output, especially for domain-specific decision trees.
- Property patterns let you match object structure directly. Match on multiple properties at once without extracting variables first—the intent is clear.
- List patterns (C# 11) handle collection shapes elegantly. Use them for first/last item access, length checks, and collection validation.
- Always include the discard catch-all. Production data is messier than you think;
_protects you. - Readability beats brevity. Extract complex patterns into helper methods or guard clauses. Code that explains itself is worth more than clever one-liners.
- Combine patterns with when guards for conditional logic. Patterns express structure;
whenhandles runtime conditions. - Order your patterns: specific before general. The first match wins, so place narrow cases above broad catch-alls.






Comments
Loading comments…