positional patterns, and tuple patterns. Each layer addresses a specific control flow problem while preserving compiler validation and runtime performance.
Step 1: Type Patterns and Null Handling
Replace explicit casting with declarative type matching. Use not null and null patterns to eliminate guard clauses.
public static string FormatPayload(object input) => input switch
{
null => "Empty",
string s => $"Text: {s}",
int i => $"Number: {i}",
_ => "Unknown"
};
Architecture decision: Prefer switch expressions over statements when the branch produces a value. The compiler enforces exhaustiveness and eliminates fall-through risks. Use _ (discard) only when all meaningful cases are covered; otherwise, let the compiler warn on missing branches.
Step 2: Property Patterns
Validate nested state without casting or temporary variables. Property patterns inspect object structure directly.
public static PricingStrategy DetermineStrategy(Order order) => order switch
{
{ Status: OrderStatus.Pending, TotalAmount: > 1000 } => PricingStrategy.Premium,
{ Status: OrderStatus.Pending, TotalAmount: <= 1000 } => PricingStrategy.Standard,
{ Status: OrderStatus.Cancelled } => PricingStrategy.None,
_ => throw new InvalidOperationException("Unhandled order state")
};
Rationale: Property patterns compile to optimized field/property access sequences. They avoid intermediate casts and keep validation logic co-located with routing decisions. Use them when domain objects expose stable, read-only state.
Step 3: Relational Patterns
Combine type inspection with value ranges without nested conditionals.
public static string ClassifyScore(double score) => score switch
{
>= 90 => "A",
>= 80 => "B",
>= 70 => "C",
>= 60 => "D",
< 60 => "F",
_ => "Invalid"
};
Architecture decision: Relational patterns evaluate top-to-bottom. Order matters. Place broader ranges after narrower ones to avoid unreachable branches. The compiler emits range checks that JIT optimizes into branchless sequences when possible.
Step 4: Positional Patterns and Records
Deconstruct immutable types using positional syntax. Requires record or init-only properties.
public record Point(double X, double Y);
public static string Describe(Point p) => p switch
{
(0, 0) => "Origin",
(var x, 0) => $"On X-axis at {x}",
(0, var y) => $"On Y-axis at {y}",
_ => $"Quadrant point ({p.X}, {p.Y})"
};
Rationale: Positional patterns leverage Deconstruct methods generated by records. They enable structural matching without exposing internal state. Use them when domain models represent data carriers rather than behavior-rich entities.
Step 5: Tuple Patterns
Match multiple independent values without creating wrapper objects.
public static string RouteRequest(string method, string endpoint) => (method, endpoint) switch
{
("GET", "/users") => "FetchUsers",
("POST", "/users") => "CreateUser",
("DELETE", "/users/{id}") => "DeleteUser",
_ => "NotFound"
};
Architecture decision: Tuple patterns excel in routing, parsing, and state machine transitions. They avoid allocation overhead compared to creating DTOs for matching purposes. Pair them with switch expressions for pure mapping functions.
Step 6: when Clauses and Advanced Guarding
Use when only when pattern matching alone cannot express the condition. Keep guards side-effect-free.
public static bool IsEligible(User user) => user switch
{
{ Age: >= 18, Role: "admin" } => true,
{ Age: >= 18, Role: "user" } when user.IsVerified => true,
_ => false
};
Rationale: when clauses compile to conditional branches evaluated after pattern matching. Overuse degrades readability and prevents compiler exhaustiveness analysis. Reserve them for cross-property validation or external state checks that cannot be encoded in the type structure.
Architecture Decisions and Rationale
- Prefer switch expressions for pure transformations. They enforce value return on all branches, eliminating implicit
null or uninitialized state.
- Align pattern matching with immutable domain models. Positional and property patterns assume stable state. Mutable objects break pattern guarantees across evaluation cycles.
- Isolate pattern routing in dedicated modules. Group related switches into static dispatch classes or extension methods. This prevents scattering matching logic across business layers.
- Leverage compiler warnings. Enable
<Nullable>enable</Nullable> and treat CS8509 (switch expression not exhaustive) as errors. The compiler is your primary validation layer.
- Avoid mixing patterns with legacy casting in the same scope. It creates cognitive dissonance and obscures intent. Migrate incrementally: replace
is/as blocks with switch expressions, then introduce property/relational patterns.
Pitfall Guide
-
Overusing positional patterns on mutable classes
Positional patterns rely on Deconstruct or primary constructors. Applying them to mutable reference types breaks encapsulation and creates stale match states. Restrict positional patterns to record types or value types with deterministic structure.
-
Ignoring exhaustiveness warnings in switch expressions
Switch expressions require every possible input to map to a value. Suppressing CS8509 or adding _ => null masks missing domain cases. Instead, model the full state space or throw InvalidOperationException with explicit context.
-
Mixing is patterns with as casting in the same block
Combining if (x is Type t) with var y = x as Type in adjacent logic creates redundant type checks and obscures control flow. Standardize on one approach per module. Pattern matching should replace, not coexist with, legacy casting.
-
Neglecting null patterns
Forgetting null or not null patterns causes NullReferenceException when matching reference types. Always handle null explicitly or use not null to restrict the match scope. C# 11+ treats null as a first-class pattern; leverage it.
-
Deep property patterns on large object graphs
Nested property patterns ({ Address: { City: "NY", Zip: "10001" } }) compile to sequential property accesses. On large graphs, this increases stack depth and prevents JIT inlining. Flatten matching by extracting relevant values before the switch or using intermediate variables.
-
Misunderstanding pattern precedence
Patterns evaluate top-to-bottom. A broad pattern placed early will shadow specific cases. Always order from most specific to most general. The compiler warns on unreachable branches, but manual review is still required for complex relational ranges.
-
Using when clauses for core routing logic
when clauses bypass compiler exhaustiveness analysis and execute after pattern matching. Heavy reliance on when degrades readability and prevents static verification. Encode conditions into the type structure or split into multiple switch expressions.
Production Best Practices:
- Enable
<AnalysisLevel>latest</AnalysisLevel> and treat pattern-related warnings as errors.
- Use records for all data carriers involved in pattern matching.
- Keep switch expressions under 15 branches; split complex routing into dedicated methods.
- Profile hot-path matching with
BenchmarkDotNet to validate JIT optimization.
- Document pattern contracts in XML comments: specify expected types, null behavior, and fallback semantics.
Production Bundle
Action Checklist
Decision Matrix
| Scenario | Recommended Approach | Why | Cost Impact |
|---|
| Routing domain types to handlers | Switch expression with type patterns | Compiler-enforced exhaustiveness, zero allocation | Low (refactoring time) |
| Validating nested DTO state | Property patterns | Eliminates casting, keeps validation co-located | Medium (model alignment) |
| Comparing scalar ranges | Relational patterns | Branchless JIT optimization, readable syntax | Low |
| Matching multiple independent values | Tuple patterns | Avoids wrapper allocation, clear intent | Low |
| Complex cross-field validation | when clauses (minimal) | Preserves pattern structure, guards edge cases | Medium (readability trade-off) |
| Legacy mutable domain models | Type patterns + explicit casting | Maintains compatibility while adopting syntax | High (migration required) |
Configuration Template
Copy into .editorconfig to enforce pattern matching standards across the solution:
[*.cs]
# Enable nullable reference types
dotnet_style_require_accessibility_modifiers = always
nullable = enable
# Treat pattern matching warnings as errors
dotnet_diagnostic.CS8509.severity = error
dotnet_diagnostic.CS8524.severity = error
dotnet_diagnostic.CS8600.severity = warning
# Prefer switch expressions over statements for value mapping
csharp_style_pattern_matching_over_is_with_cast_check = true:suggestion
csharp_style_prefer_switch_expression = true:suggestion
# Enforce modern C# version
dotnet_code_style_unused_parameters = all:suggestion
Add to .csproj for compiler optimization:
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<AnalysisLevel>latest</AnalysisLevel>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
</PropertyGroup>
Quick Start Guide
- Upgrade toolchain: Ensure Visual Studio 2022 (17.8+) or VS Code with C# Dev Kit. Set project target to
.NET 8 or higher.
- Enable nullable and analysis: Add
<Nullable>enable</Nullable> and <AnalysisLevel>latest</AnalysisLevel> to your .csproj. Apply the .editorconfig template above.
- Convert a casting block: Replace an existing
if (x is Type t) or var y = x as Type block with a switch expression. Verify CS8509 is resolved.
- Introduce property/relational patterns: Refactor nested
if conditions into property or relational matches. Order branches specific-to-general.
- Profile and validate: Run
dotnet build to confirm zero pattern warnings. Execute unit tests covering null, edge, and fallback cases. Benchmark hot paths if routing logic is performance-critical.