C# Recursive Pattern Matching Explained
c# recursive pattern: Learn how recursive patterns in C# simplify complex data inspections, using nested positional and property patterns in switch expressions.
When inspecting deeply nested data structures, the logic often becomes a chain of type checks, null checks, and property accesses. The c# recursive pattern feature lets you express that nested validation in a single pattern that follows the shape of the data itself. Instead of writing several if statements to peel apart an object layer by layer, you can describe the expected structure directly in a switch expression or is expression, and the compiler will break it down for you.
A recursive pattern is any pattern that appears inside another pattern. C# uses this term for the fact that a positional or property pattern can contain additional patterns, including further recursive patterns. This is what allows a single pattern to describe a multi-level structure in one expression.
Positional Patterns as the Foundation
A positional pattern is used with a type and a parenthesized list of sub-patterns. Each sub-pattern corresponds to a positional property of the type, typically the properties exposed via a deconstructor. For example, a Point record with X and Y properties can be matched with a positional pattern.
public record Point(int X, int Y); string Classify(Point p) => p switch { (0, 0) => "origin", (0, _) => "on y-axis", (_, 0) => "on x-axis", _ => "elsewhere" };
The pattern (0, _) uses a positional sub-pattern. The _ discard matches any value. This is already a recursive pattern because the positional pattern contains constant patterns and a discard pattern. The recursion becomes more visible when you nest positional patterns inside other positional patterns.
Nesting Patterns with Records and Tuples
Records provide a clean way to build nested structures, and they expose deconstructors by default. Consider a Location record that contains a Point and a label.
public record Location(Point Coordinates, string Name); string Describe(Location loc) => loc switch { Location(Point(0, 0), _) => "origin point", Location(Point(0, var y), _) => $"on y-axis at {y}", Location(_, string name) when name.StartsWith("HQ") => "headquarters", _ => "other" };
Inside the Location(Point(0, var y), _) pattern, the inner Point(0, var y) is itself a positional pattern that captures the y-coordinate into a variable. That is a recursive pattern. The compiler recursively decomposes loc first into its Coordinates and Name, then further decomposes Coordinates into its X and Y.
This same nesting works with tuples. A tuple inside a positional pattern can be matched directly, as long as the tuple elements are accessible.
var data = ((1, 2), "pair"); string Explain(( (int, int), string) input) => input switch { ((0, 0), _) => "zero pair", ((var a, var b), string label) => $"{label}: {a},{b}", _ => "unknown" };
Here ((var a, var b), string label) is a recursive pattern because the outer tuple pattern contains a nested tuple pattern.
Property Patterns for Non-Positional Types
Not every type exposes a deconstructor. Property patterns allow matching on individual property values without needing a positional form. You can combine property patterns with positional patterns to handle mixed types.
public class Customer { public string Name { get; set; } public Address Address { get; set; } } public class Address { public string City { get; set; } public string Country { get; set; } } string GetRegion(Customer customer) => customer switch { { Address: { Country: "US", City: var city } } => $"US: {city}", { Address: { Country: "CA" } } => "Canada", { Address: null } or { Address: { Country: null } } => "Unknown", _ => "Other" };
The { Address: { Country: ... } } pattern is a recursive property pattern. It matches the Address property and then recursively applies another property pattern to Country. This works when you need to inspect read-only properties that do not have a deconstructor.
Combining Type Tests and Recursive Patterns
Recursive patterns are especially useful when a base type has multiple subtypes that have different structures. You can use a type pattern as an outer pattern and then nest a property or positional pattern inside it.
public abstract class Shape { } public record Circle(double Radius) : Shape; public record Rectangle(double Width, double Height) : Shape; string Describe(Shape shape) => shape switch { Circle(0) => "point", Circle(double r) => $"circle radius {r}", Rectangle(0, 0) => "empty rectangle", Rectangle(var w, var h) when w == h => "square", Rectangle(var w, var h) => $"rectangle {w}x{h}", _ => "unknown shape" };
Here Circle(0) is a positional pattern that also performs a type check. Rectangle(var w, var h) combines type test, positional decomposition, and variable capture. The when clause adds a guard. This pattern of type testing plus nested decomposition is a common practical use of the c# recursive pattern.
The order of arm evaluation matters. The compiler checks patterns in the order they appear, so more specific patterns must come before more general ones. For instance, Rectangle(0, 0) must appear before Rectangle(var w, var h) because the latter would match everything.
Switch Expressions and Pattern Combinators
Recursive patterns are often used inside switch expressions, but they also work in if statements with the is operator.
if (shape is Rectangle(var w, var h) && w > 0) { Console.WriteLine($"Width: {w}"); }
You can also use the and, or, and not pattern combinators to build more complex recursive conditions.
string Categorize(object obj) => obj switch { int n when n > 0 and n < 100 => "small positive", string s when s.Length > 0 and not null => "non-empty string", Point(0, 0) or Point(0, _) => "on y-axis", _ => "other" };
The or pattern Point(0, 0) or Point(0, _) is redundant because _ matches any value, but it demonstrates that each arm can contain alternative patterns. The not null pattern is useful to avoid accidental null matches. Note that the compiler will flag unreachable patterns or unnecessary conditions in some cases, so it is worth paying attention to warnings.
Compile-Time Exhaustiveness and Warnings
A switch expression over an arbitrary object is not exhaustive unless you provide a catch-all arm. For an enum or a sealed type hierarchy, the compiler can perform exhaustiveness analysis. When you use recursive patterns that only match a subset of possible shapes, you must include a _ arm to avoid runtime exceptions. The compiler will also warn about unreachable arms if an earlier pattern always matches.
For example, Circle(_) will match all Circle instances, so any later Circle(...) pattern is unreachable. The compiler emits a warning for this. Pay attention to those warnings because they often indicate a logic error.
Performance and Overhead Concerns
The recursive pattern feature does not use reflection. The compiler generates direct type checks and property accesses. For a record, the generated deconstructor simply reads the backing fields. This means that recursive pattern matching is typically as fast as manual property access and type checks. There is no dynamic dispatch overhead beyond what the type test requires.
However, the order of property access follows the pattern order. If a pattern matches a property deep in a hierarchy, the compiler still performs earlier checks first. There is no short-circuiting that skips property reads unless the outer pattern fails. For most code, this overhead is negligible. If performance is critical, avoid using patterns that require reading many properties across large collections in a hot loop, because each property access has a cost.
Another point is that pattern matching on nullable value types requires care. int? is not directly supported as a positional pattern, but you can use a property pattern with HasValue and Value.
string Describe(int? value) => value switch { { HasValue: true, Value: var v } when v > 0 => $"positive {v}", { HasValue: true, Value: 0 } => "zero", _ => "null or negative" };
This pattern is recursive because Value is a property that contains another pattern.
Maintainability and Where Recursive Patterns Shine
Recursive patterns reduce boilerplate and keep the validation logic in a single visible place. This is a real maintainability benefit because the pattern mirrors the shape of the data, making the expected structure obvious to readers.
However, there is a tradeoff. When the pattern becomes too large, it can be harder to read than a sequence of if statements, especially for unfamiliar readers. A pattern that spans more than a few lines may need to be extracted into a named method or a separate pattern variable using the var pattern.
var isOrigin = shape is Point(0, 0);
You can also use a recursive pattern inside an if with a pattern variable to use later in the block.
if (shape is Rectangle(var w, var h) && w == h) { Console.WriteLine($"Square side: {w}"); }
The var pattern captures the matched value, and you can use it in subsequent expressions.
When a structure changes, recursive patterns may require updates in several places. Since the pattern describes the entire structure, a new property may not affect the pattern unless you need to match it. This is usually manageable.
Compatibility with Older C# Versions
Recursive patterns rely on language features introduced in C# 7.0 and expanded in later versions. Positional patterns and property patterns have existed since C# 7.0. The and, or, and not combinators were added in C# 9.0. Extended property patterns that allow nested property patterns like { Address: { City: "Seattle" } } were also part of C# 9.0. If you need to support an older codebase, you must ensure the compiler language version is set appropriately.
In .NET Framework environments, you may be limited to C# 7.0, which means you cannot use the and, or, or not combinators. You can still use positional and property patterns. Switch expressions were introduced in C# 8.0, so for older code you must use switch statements or if with is patterns.
Be aware that the deconstruct method must be accessible. For a record, the compiler generates it. For a custom class, you must implement a public Deconstruct method for positional patterns to work.
public class Pair { public int First { get; } public int Second { get; } public Pair(int first, int second) => (First, Second) = (first, second); public void Deconstruct(out int first, out int second) => (first, second) = (First, Second); }
Without Deconstruct, a positional pattern will not compile.
Choosing When to Use Recursive Patterns
Use recursive patterns when the shape of the data is known at compile time and you need to distinguish between a limited set of forms. This is common when parsing abstract syntax trees, handling discriminated unions modeled through inheritance, or validating deserialized DTOs.
Do not use recursive patterns when the structure is highly dynamic and requires reflection every time. The compiler cannot generate efficient code for unknown shapes.
Also consider the readability for future maintainers. A deep pattern is concise, but if the nesting exceeds two or three levels, it might be clearer to break the logic into helper functions. For example, instead of matching Location(Point(0, var y), _), you could extract a method IsOnYAxis(Point p) and call it from the guard:
if (loc is Location(var point, _) && IsOnYAxis(point)) { // ... }
This separates the decomposition from the condition, making the condition reusable.
Another scenario is when you need to match a property that is itself a collection. The list pattern, introduced in C# 11, allows matching against array or list shapes, and it can be combined with recursive patterns.
int[] numbers = { 1, 2, 3 }; if (numbers is [1, var second, _]) { /* second is 2 */ }
While the list pattern is technically a different feature, it uses the same recursive ideology: break down structure in a single expression. You can nest list patterns inside positional patterns for complex data structures.
In the end, the c# recursive pattern is a tool that, when used with restraint, makes code more expressive and less error-prone. The cost is a learning curve and a potential for over-abstraction. For most developers, the syntax becomes natural after a few uses, and the clarity gains outweigh the occasional complexity.