Back to Blog
C#

C# Positional Pattern: Matching Objects by Shape

c# positional pattern: Learn how the C# positional pattern matches objects by their shape using deconstruction, with practical examples and common pitfalls.

C#Pattern MatchingSwitch ExpressionsDeconstructionC# 9
Illustration of a C# positional pattern matching an object by deconstructing it into parts, with a switch expression in the background.

The C# positional pattern lets you match an object based on its shape by deconstructing it into its constituent parts. It is a core feature of pattern matching introduced in C# 9, and it becomes especially powerful when combined with switch expressions and recursive patterns. This article explains the syntax, shows realistic usage, and covers the edge cases that trip up developers moving from traditional if-else chains.

What Is a Positional Pattern in C#?

A positional pattern is a pattern that matches an expression by deconstructing it and then applying sub-patterns to the resulting values. The syntax uses parentheses after a type name, similar to a constructor call or a deconstruction assignment. For example, if you have a record type Point with two properties X and Y, you can match a Point value like this:

if (point is Point(0, 0)) { Console.WriteLine("Origin"); }

Here Point(0, 0) is a positional pattern. It first checks that point is of type Point, then deconstructs it into two values, and finally applies the constant patterns 0 and 0 to those values. The pattern matches only when both coordinates are zero.

The key requirement is that the type must expose a suitable Deconstruct method. For records, the compiler generates one automatically based on the primary constructor parameters. For classes, you must implement Deconstruct yourself if you want to use positional patterns on them.

Basic Syntax and Deconstruction

To use a positional pattern, the target type must have an accessible Deconstruct method with out parameters. The pattern's argument list must match the number and order of those parameters. Here is a class that implements Deconstruct explicitly:

public class Rectangle { public int Width { get; } public int Height { get; } public Rectangle(int width, int height) { Width = width; Height = height; } public void Deconstruct(out int width, out int height) { width = Width; height = Height; } }

Now you can match a Rectangle by its dimensions:

var rect = new Rectangle(10, 20); if (rect is Rectangle(10, 20)) { Console.WriteLine("10x20 rectangle"); }

You can also use discard patterns to ignore certain positions:

if (rect is Rectangle(10, _)) { Console.WriteLine("Width is 10, height ignored"); }

The underscore _ discards that value. This is useful when you only care about a subset of the deconstructed values.

In addition to constant patterns, you can use any pattern inside the parentheses, including relational patterns, property patterns, or even nested positional patterns. For instance:

if (rect is Rectangle(> 5, < 50)) { Console.WriteLine("Width > 5 and height < 50"); }

This matches a rectangle whose width is greater than 5 and height is less than 50.

Using Positional Patterns in Switch Expressions

Positional patterns shine in switch expressions, where they let you express complex branching logic concisely. Consider a shape hierarchy with Circle, Rectangle, and Triangle types, each having a Deconstruct method. You can compute area with a single switch expression:

double GetArea(object shape) => shape switch { Circle(0) => 0, Circle(double radius) => Math.PI * radius * radius, Rectangle(double w, double h) => w * h, Triangle(double b, double h) => 0.5 * b * h, _ => throw new ArgumentException("Unknown shape", nameof(shape)) };

Each case uses a positional pattern to match the shape and bind its deconstructed values to variables. The _ discard pattern handles any other type. The compiler checks that the patterns are exhaustive when a default case is present, but you still need to provide a fallback to avoid runtime exceptions for unexpected types.

You can also combine positional patterns with property patterns for finer-grained matching. For example, you might want to match a rectangle that is a square:

shape switch { Rectangle(double w, double h) when w == h => "Square", Rectangle(_, _) => "Rectangle", _ => "Other" }

Here the when clause adds an additional condition that cannot be expressed directly in the positional pattern itself.

Combining with Property Patterns and Recursive Patterns

Positional patterns are often used recursively. A nested positional pattern appears inside another positional pattern, allowing you to match deeply nested structures. For example, suppose you have a Person record with a Name and an Address record, and the Address has City and PostalCode. You can match a person who lives in a specific city:

if (person is Person(_, Address("Seattle", _))) { Console.WriteLine("Lives in Seattle"); }

This works because Address also has a Deconstruct method (generated for records). The pattern first deconstructs the Person, discards the name, then applies a positional pattern to the Address value, which deconstructs it into city and postal code. The constant pattern "Seattle" matches the city, and the discard ignores the postal code.

You can mix positional patterns with property patterns when you need to access a property that is not part of the deconstruction. For instance, a record might have additional properties beyond the primary constructor parameters. In that case, you can use a property pattern inside the positional pattern:

if (point is Point(0, 0) { Label: "origin" }) { Console.WriteLine("Origin with label"); }

This combines the positional pattern for the coordinates with a property pattern for the Label property. The syntax is Type(pattern) { property: pattern }.

Common Mistakes and Edge Cases

One common mistake is assuming that positional patterns work with any type without a Deconstruct method. They do not. If the type does not expose a Deconstruct method, the compiler will raise an error. For classes, you must implement Deconstruct manually. For records, the compiler generates it based on the primary constructor, but only if you use the primary constructor syntax. If you define a record with an explicit constructor, you may need to add Deconstruct yourself.

Another edge case is the order of arguments. The positional pattern must match the order of the Deconstruct method's out parameters. If you swap them, the pattern will either fail to compile or match incorrectly. For example, if Deconstruct for a Point is (out int x, out int y), then Point(0, 0) matches x=0, y=0. Using Point(0, 0) is fine, but Point(0, 0) is the same. However, if you write Point(y: 0, x: 0), that is not allowed; positional patterns do not support named arguments.

Also, be careful with nullable types. A positional pattern on a nullable value type will not match null unless you add a null pattern explicitly. For example, int? value = null; if (value is int(0)) will not match because the pattern checks the type first. You need value is null or value is int 0 (using a constant pattern) to handle nulls.

Performance and Maintainability Considerations

Pattern matching in C# is compiled to efficient code, often using type checks and direct property access. For simple positional patterns, the runtime cost is similar to an is check followed by a cast and property reads. However, the actual cost depends on the Deconstruct method implementation. If Deconstruct performs expensive work, such as allocating a collection or computing a hash, that cost is paid every time the pattern is evaluated. Keep Deconstruct methods lightweight and free of side effects.

From a maintainability perspective, positional patterns improve readability by keeping the shape of the object visible in the pattern itself. They reduce the need for multiple if blocks and temporary variables. However, overusing them can make code harder to follow if the patterns become deeply nested. Prefer extracting complex pattern logic into separate methods or using property patterns when you only need a few fields.

Another consideration is that positional patterns are tightly coupled to the Deconstruct method's signature. If you change the order or number of parameters, all patterns using that type must be updated. This can be a maintenance burden in large codebases. To mitigate this, keep Deconstruct methods stable and document the expected order.

Compatibility and Language Version Requirements

Positional patterns are available in C# 9 and later. They require a compiler that supports C# 9, such as the one shipped with .NET 5 or newer. The runtime does not need special support because pattern matching is compiled into regular IL. However, if you are using older frameworks, you may need to install a newer compiler or use a language version setting. For example, in .NET Framework projects, you can still use C# 9 syntax if you set the language version to latest in the project file, but you must ensure the build environment supports it.

Records, which are a common source of automatically generated Deconstruct methods, are also introduced in C# 9. If you are working with classes, you can implement Deconstruct manually and use positional patterns with them. This means the feature is not limited to records; it works with any type that follows the deconstruction convention.

When targeting multiple frameworks, be aware that the C# language version is a compiler feature, not a runtime feature. As long as your build tools support C# 9, you can use positional patterns even when targeting .NET Core 3.1 or .NET Framework 4.8. The generated code will run on those runtimes because it only uses standard IL instructions.

Finally, consider the interaction with nullable reference types. If a type has a nullable property, the Deconstruct method may return a null value. The positional pattern will treat that as a regular value, so you need to handle nulls in the sub-patterns. For example, if a Person record has a nullable MiddleName, a pattern like Person(_, null, _) will match when the middle name is null. This is useful for filtering, but it can be surprising if you expect the pattern to skip nulls automatically.

Positional patterns are a powerful addition to the C# pattern matching toolbox. They let you express shape-based logic in a declarative way, reducing boilerplate and making the intent of the code clearer. By understanding the deconstruction requirement, the syntax, and the common pitfalls, you can use them effectively in your own codebases.

c# positional pattern: Practical Usage and Code Examples | RYUSLOG DEV