TL;DR: C# pattern matching simplifies conditionals with property, relational, logical, and nested patterns. Write clearer code, reduce boilerplate, and safely match data shapes with switch, when, and exhaustive patterns.
If you’ve been writing C# for a while, you probably remember the days of building complex business logic with deeply nested if-else
chains and clunky switch
statements. It worked, but it often led to code that was hard to read and even harder to maintain. While pattern matching isn’t new, the enhancements in C# 9 and 10 have transformed it from a simple type-checking tool into a powerful, declarative language feature.
Getting fluent with modern patterns, property, logical, and relational, will fundamentally change how you write conditional logic. Your code will become more expressive, concise, and focused on the shape of your data rather than the imperative steps to inspect it. Let’s dive into some practical tricks.
The Evolution of is
: More Than Just a Type Check
You’ll often see the is
keyword used for a simple type check. But since C# 9, it’s become a powerhouse for checking an object’s properties in a single, clean expression. This is called a property pattern.
Imagine you have a method to validate an incoming API request DTO.
The Old Way: A Chain of Checks
public bool IsValidRequest(object request)
{
if (request is CreateUserRequest dto)
{
// Now check the properties...
if (dto.Username != null && dto.Password.Length > 8 && !dto.IsDisabled)
{
return true;
}
}
return false;
}
public record CreateUserRequest(string Username, string Password, bool IsDisabled);
This is okay, but it’s a nested mess. The logic is spread across multiple lines and conditional blocks.
The Modern Way: A Declarative Property Pattern
With property patterns, you can do this in one line. Notice how clean this gets:
public bool IsValidRequest(object request)
{
// Check type and properties in one go
return request is CreateUserRequest {
Username: not null,
Password.Length: > 8,
IsDisabled: false
};
}
public record CreateUserRequest(string Username, string Password, bool IsDisabled);
This works because we’re describing the shape of a valid object directly in the is
expression. We’re not just checking the type; we’re simultaneously checking its properties using relational patterns (> 8
) and a negated constant pattern (not null
).
Supercharging switch
Expressions
The switch
expression (introduced in C# 8) was already a huge improvement over the switch
statement. C# 9+ makes it even better by allowing all these new pattern types inside its arms. This is perfect for processing different kinds of events from a message queue or handling API responses.
Let’s say we’re processing HttpResponseMessage
objects.
The Old Way: switch
Statement with if
public string ProcessResponse(HttpResponseMessage response)
{
switch (response.StatusCode)
{
case System.Net.HttpStatusCode.OK:
return "Success!";
case System.Net.HttpStatusCode.Unauthorized:
case System.Net.HttpStatusCode.Forbidden:
return "Permission denied.";
case System.Net.HttpStatusCode.InternalServerError:
if (response.RequestMessage.Method == HttpMethod.Post)
{
return "Critical failure during a POST operation.";
}
return "A server error occurred.";
default:
return "Some other response.";
}
}
The Modern Way: switch
Expression with Nested Patterns
The switch
expression lets us combine all of this into a much more readable and declarative form. We can even use nested property patterns to inspect the RequestMessage
.
public string ProcessResponse(HttpResponseMessage response) => response switch
{
// Relational and logical patterns for status codes
{ StatusCode: >= System.Net.HttpStatusCode.OK and < System.Net.HttpStatusCode.MultipleChoices }
=> "Success!",
// 'or' pattern for multiple status codes
{ StatusCode: System.Net.HttpStatusCode.Unauthorized or System.Net.HttpStatusCode.Forbidden }
=> "Permission denied.",
// Nested property pattern with a guard
{ StatusCode: System.Net.HttpStatusCode.InternalServerError,
RequestMessage: { Method: { Method: "POST" } } }
=> "Critical failure during a POST operation.",
// Catch-all for other server errors
{ IsSuccessStatusCode: false } => "A generic failure occurred.",
_ => "Some other response."
};
Notice how we use and
, or
, and relational operators directly in the case arms. The code reads like a series of rules rather than a procedural checklist.
Fine-Tuning with the when
Clause
Sometimes, a pattern can’t express a condition on its own, especially if it involves calling a method or accessing something outside the object being matched. That’s where the when
clause comes in. It acts as an additional filter, or a “guard,” that runs only if the main pattern matches.
A great use case is checking user permissions that require a service call.
public string GetDashboardUrl(User user) => user switch
{
// The 'when' clause is a guard that runs after the pattern matches
{ IsAdmin: true } => "/admin/dashboard",
// Check the pattern first, then evaluate the 'when' clause
{ IsEmployee: true } u when _permissionService.HasPremiumAccess(u.Id)
=> "/employee/premium-dashboard",
{ IsEmployee: true } => "/employee/basic-dashboard",
_ => "/login"
};
// Dummy service for demonstration
private readonly IPermissionService _permissionService = new PermissionService();
This trick helps when your logic is too complex for a simple property check. The user
object is matched first, and only then is HasPremiumAccess()
called. This avoids running expensive checks on objects that don’t even match the basic pattern.
Summary Table of Modern Patterns
Here’s a quick reference table to help you remember the different pattern types and where to use them.
Pattern Type | Syntax Example | Common Use Case |
---|---|---|
Property Pattern | { Name: "John", Age: > 30 } | Checking multiple properties of an object in one expression. |
Relational Pattern | > 100 , <= 50 | Comparing numerical or ordered values. |
Logical Pattern | > 10 and < 20 , not null , "A" or "B" | Combining other patterns with boolean logic. |
Parenthesized | (> 10 and < 20) or 50 | Grouping patterns to control precedence explicitly. |
Nested Pattern | { Order: { Amount: > 1000 } } | Matching against properties of a nested object. |
when Clause | User u when u.IsActive() | Applying a complex, external condition to a pattern. |
Common Mistakes and Gotchas
when
Isn’t a Short-Circuit: The main pattern is always evaluated before thewhen
clause. Don’t rely onwhen
to prevent aNullReferenceException
if the pattern itself could fail on anull
object.switch
Expression Exhaustiveness: The compiler forcesswitch
expressions to be exhaustive, meaning all possible inputs must be handled. This is a feature, not a bug! Use the discard_
for a catch-all case.- Order Matters: In a
switch
expression, the first matching arm wins. Place more specific patterns before more general ones. A{ IsSuccessStatusCode: false }
would catch a500
error before a more specific{ StatusCode: 500, ... }
if placed first.
Takeaway: A New Mental Model for Conditionals
The best way to start using these features is to look for code you’ve already written. Find those long if-else if
chains or switch
statements with lots of internal logic. You’ll be surprised how many can be refactored into a single, elegant switch
expression.
Think of modern pattern matching as a form of smart, conditional destructuring. You’re not just checking a type; you’re declaring the shape and state of the data you expect, pulling out the pieces you need, and evaluating them all in one cohesive, readable block. It’s one of the most powerful tools in modern C# for writing clean, maintainable, and declarative code.
FAQ
What is property pattern matching in C# and why is it useful?
How do relational and logical patterns simplify conditional checks?
> 10
or <= 50
. This reduces the need for nested if
statements and makes your intent clearer.What role do logical operators like and
, or
, and not
play in pattern matching?
and
, or
, and not
allow you to combine multiple conditions in a single pattern. This enables more readable and maintainable code compared to chaining multiple checks.How do you use the when
clause to add conditions to patterns?
when
clause acts as a guard, applying an additional condition after the main pattern matches. It’s useful for complex checks that can’t be expressed directly in the pattern, such as method calls or external service checks.What are nested patterns in C# and when should you apply them?
Why is the order of patterns important in switch expressions?
How does the compiler ensure that switch expressions are exhaustive?
switch
expression. Use the discard _
pattern as a catch-all to ensure your code is exhaustive and safe.What are common pitfalls to avoid with pattern matching in C#?
when
clause for null checks (the main pattern runs first), not handling all cases in a switch
expression, and placing general patterns before specific ones, which can cause logic errors.How does pattern matching make C# code more maintainable?
Does pattern matching replace all if-else
and switch
statements in C#?
Related Posts
- Prefer Interfaces Over Abstract Classes in C#: Build Flexible, Testable, and Maintainable Code
- Encapsulation Best Practices in C#: Controlled Setters vs Backing Fields
- When Inheritance Still Makes Sense in C#: Polymorphism Without Swapping
- Guard Clauses in C#: Cleaner Validation and Fail-Fast Code
- Building Custom Collection Types in C#: IEnumerable, Indexers, and Domain Logic