AlgoMaster Logo

Positional Patterns

Medium Priority33 min readUpdated June 6, 2026

A positional pattern lets you match an object by pulling several values out of it at once and checking them in a single expression. Instead of writing point.X == 0 && point.Y == 0, you write point is (0, 0) and the compiler does the deconstruction for you. This lesson covers the two forms of positional matching (tuple patterns and Deconstruct-based patterns), how to wire your own types into the system, how positional patterns combine with property patterns, and the small set of mistakes that trip people up the most.

What a Positional Pattern Is

A positional pattern is a parenthesized list of sub-patterns matched against the parts of a value by position. The match succeeds only when every sub-pattern matches its corresponding part. The simplest case is a tuple, where the parts are clear.

The pattern ("Paid", > 100m) is two sub-patterns wrapped in parentheses. The first sub-pattern ("Paid") is a constant pattern that demands an exact match. The second (> 100m) is a relational pattern that succeeds when the value is strictly greater than 100. Both have to match for the whole thing to succeed.

Tuples are not the only thing you can match this way. Any type with a Deconstruct method can be matched positionally, including positional records, structs you write yourself, and types from libraries that expose deconstruction. The pattern syntax stays the same; the compiler figures out which Deconstruct to call.

The two examples produce the same result, but the second one isn't matching a tuple. It's matching an Order record. The positional pattern works because the record's positional declaration generates a Deconstruct(out string Status, out decimal Amount) method, and the compiler uses that to pull the parts out before applying the sub-patterns. Records are covered later in this lesson.

Positional patterns belong to the same is and switch machinery as every other pattern. They compose with property patterns, with var, with discards, and they work inside switch expressions just as cleanly as in if statements.

The order of arms matters. Switch expressions are evaluated top to bottom, and the first match wins. A wider pattern like ("Paid", _) placed above a narrower one like ("Paid", > 100m) would shadow it and the compiler would warn you.

Tuple Patterns

Tuple patterns are the version of positional matching you get when the value being matched is a ValueTuple. You don't need a Deconstruct method; the compiler treats a tuple's elements as its positional parts directly. This is the simplest version of the feature and a good starting point.

The tuple's elements get matched by position, the same way you'd assign them with var (a, b) = result;. The names you give the tuple fields (Status, ItemCount) are not used by the pattern unless you opt into named tuple patterns, which we'll cover further down.

Tuple patterns are particularly clean for methods that return multiple values. A search method that returns a status and a result is the textbook example.

The LookupPrice method returns a (bool, decimal) tuple, and the switch expression matches both elements at once. There's no need for an if (found) check around a price comparison; the pattern says what we mean.

Tuple patterns also work when you want to compare two unrelated values together. The classic example is direction logic where neither variable alone tells the full story.

The two values, currentStock and incoming, are combined into a temporary tuple just for the match. The pattern then describes the relationship between them. Without tuple patterns this kind of logic spreads across nested if statements.

Forming a tuple to feed into a pattern allocates nothing on the heap. ValueTuple is a struct and lives on the stack. The compiler typically elides the temporary entirely once the pattern is lowered, so there's no overhead compared to a hand-written if/else chain.

The number of sub-patterns in a tuple pattern has to match the tuple's arity exactly. Trying to match a (string, int) tuple against a three-element pattern is a compile error, not a runtime check.

That's a deliberate design decision. Positional patterns are statically checked, so the compiler catches arity mismatches before the code runs. If you need flexibility around how many parts you match, that's what discards and property patterns are for.

Deconstruct: Making Your Own Types Match

Tuples are convenient, but most of your code probably uses classes or records, not tuples. To make a class match positionally, you give it a Deconstruct method. The compiler then treats the type as if its out parameters were the parts of the value.

A Deconstruct method is an instance method (or extension method) that returns void and exposes parts of the object as out parameters. The naming convention says you should call it Deconstruct, and the compiler enforces that.

The Deconstruct method is what powers the pattern. The compiler sees origin is (0, 0), looks for a Deconstruct on Coordinate with two out parameters, finds one, and rewrites the match to call it. The out parameter names (x, y) don't matter for unnamed positional patterns; only the parameter count and order do.

That last sentence is worth dwelling on. The order of parameters in `Deconstruct` is part of the public contract. A caller writing point is (3, 4) is relying on the first out to be the X and the second to be the Y. If you ever rewrite Deconstruct to swap the parameter order, every call site silently flips its meaning. The code keeps compiling, the tests keep passing if they're not specific enough, and the bug ships.

Treat Deconstruct like any other part of your public API. Once it's out there, the parameter order is fixed.

The Deconstruct method itself can do anything you want, including computation. You're not limited to mirroring fields; you can expose derived values as the deconstructed parts.

The third part of the pattern (inStock) is computed inside Deconstruct. From the call site, that's invisible. The pattern reads as cleanly as if Product had an InStock field. This is a nice way to expose a "view" of an object that's tailored for pattern matching, without polluting the type's public surface with extra properties.

Multiple Deconstruct Overloads

A type can declare more than one Deconstruct method, as long as the overloads have different numbers of out parameters. The compiler picks the overload based on how many sub-patterns the positional pattern contains.

The same type, two different patterns, two different Deconstruct overloads picked. Two-pattern positional matches resolve to the two-parameter overload; three-pattern matches resolve to the three-parameter one. The compiler matches on arity first; if more than one overload has the same arity, you get an error and have to rename one or use a property pattern instead.

This works well for types that have a "primary view" and a "detailed view." The two-part Deconstruct is what most pattern-matching call sites need; the three-part one is for cases where currency, locale, or some other secondary field becomes relevant.

What you can't do is overload Deconstruct purely on the types of the out parameters when the arities match. Two overloads with the same number of out parameters but different types compile, but the positional pattern can't pick between them. You'll get a compile error at the pattern saying the call is ambiguous.

For practical code, stick to one overload per arity. If you find yourself wanting two overloads at the same arity, that's usually a sign the two views deserve different method names entirely.

Extension Method Deconstructors

Deconstruct can be an extension method instead of an instance method. This is how you make a type you don't own match positionally: define an extension Deconstruct in your own code, and pattern matching picks it up automatically.

The TimeSpan type is in the BCL and you can't add methods to it, but you can write an extension Deconstruct. The pattern (> 2, _) then works as if TimeSpan had a built-in two-part deconstruction. The compiler looks for an instance Deconstruct first; if it doesn't find one with the right arity, it looks for an accessible extension method.

This is genuinely useful for matching against library types. KeyValuePair<TKey, TValue> already deconstructs to (key, value) because the BCL provides one, but for older types or third-party types that don't, an extension is the only way in.

One caveat: extension methods only get picked up if the static class containing them is in a using-imported namespace. If your Deconstruct extension is in MyApp.Extensions and the file doing the pattern match doesn't using MyApp.Extensions;, the pattern won't compile. The error message is unhelpful in that case (it just says no Deconstruct was found), so when you see "missing Deconstruct" errors on a type that should have one, check the using directives first.

Extension Deconstruct calls have the same cost as instance method calls. The compiler emits a static method call into the IL either way. Pattern matching itself adds no overhead beyond what a hand-written deconstruction would.

Positional Records

Positional records are the most concise way to get a Deconstruct method. When you declare a record with a parameter list, the compiler generates the constructor, the public properties, the Deconstruct method, the Equals and GetHashCode overrides, and the ToString override all at once.

The auto-generated Deconstruct follows the parameter order declared in the record header. record Customer(string Tier, string Country) produces Deconstruct(out string Tier, out string Country). The pattern ("Premium", "US") lines up with that order.

This is also the most common place developers introduce silent ordering bugs. If you reorder the parameters in the record header, every positional pattern matching the record flips its meaning.

Every is ("Premium", "US") in the codebase now asks "is the country Premium and the tier US?" which is never true. The compiler can't help; both forms are syntactically valid. If you're going to reorder record parameters, search the codebase for positional patterns against that record and update each one.

The fix when you want the parameter order to be flexible is to use property patterns or named tuple patterns (covered below) instead. Property patterns are immune to the ordering trap because they match by name.

Records can also have a manually written Deconstruct that overrides the generated one, or extra Deconstruct overloads alongside it.

The auto-generated two-parameter Deconstruct is still there, and the manual three-parameter one is added alongside. Two- and three-element positional patterns both compile, each routing to the right overload.

How Deconstruct Drives a Positional Pattern

It helps to see what the compiler does when it lowers a positional pattern. The match order is ("Paid", > 100m) against a record is roughly equivalent to this hand-written code.

The compiler inserts a call to Deconstruct, captures the out values into temporary variables, then evaluates each sub-pattern against the corresponding temporary. The whole thing succeeds only if every sub-pattern succeeds.

The diagram shows the two-step flow: deconstruct first, then match each sub-pattern in order. If the value being matched is null, the compiler inserts a null check before the Deconstruct call and short-circuits to "no match" rather than throwing a NullReferenceException. That's why order is ("Paid", _) is safe even when order could be null.

The pattern handles null cleanly. No exception, no need for a separate null check before the is expression.

Combining Positional with Property Patterns

Positional patterns and property patterns are both forms of recursive pattern, and the language lets you mix them in the same expression. The combination is written as Type(positional, parts) { Name: value, ... }. The positional part matches by position; the brace block matches additional properties by name.

The positional part ("Premium", "US") matches the two record-constructor parameters by position. The brace block { OrderCount: > 10, HasActiveSubscription: true } matches the two additional properties by name. The whole pattern only succeeds when every sub-pattern matches.

Use this pattern when a type has more state than its positional view exposes. A record's positional view typically covers the "primary key" fields. Anything in addition (computed properties, optional metadata, audit fields) is better matched by name through the property block.

You can also use a type pattern in front of the positional pattern. This is required when the variable being matched is typed as a base class or interface and you need to assert the concrete type first.

The leading type name (EmailNotification, SmsNotification) tells the compiler which concrete type to check against and which Deconstruct to call. Without the type prefix the match would fail at compile time because Notification itself doesn't have a two-part Deconstruct.

That third arm uses a property pattern ({ Length: > 100 }) as the second positional sub-pattern. Each slot of a positional pattern can itself be any pattern, including a property pattern, a relational pattern, a type pattern, or another positional pattern.

Parenthesized Patterns and Grouping

A positional pattern is written with parentheses, and parentheses can also be used to group patterns for clarity or to control precedence in combined patterns. The two uses look similar but mean different things.

A positional pattern with one element looks the same as a parenthesized single pattern. The compiler distinguishes them by what's inside.

(> 0 and < 100) is a single pattern (> 0 and < 100) wrapped in parentheses for grouping. The compiler does not look for a one-parameter Deconstruct on int because no such thing exists in the BCL. The parentheses are purely syntactic.

Parenthesized patterns earn their keep when combining logical patterns. The and, or, and not operators have a precedence (not binds tightest, then and, then or), and explicit parentheses make the intent unambiguous.

The first sub-pattern of the first arm is ("Paid" or "Shipped"), a parenthesized or pattern grouped to make clear that the or belongs to that slot and not to the whole positional pattern. The second arm omits the inner parentheses because they're not needed; precedence already puts the or inside the slot. Both styles are valid; the parenthesized form is more explicit and harder to misread.

Nested positional patterns are also allowed. A positional pattern slot can itself be a positional pattern.

The outer positional pattern matches the Customer record's two parts: the name (matched with _, a discard) and the address. The address slot is itself a positional pattern ("Bangalore", "India") that matches the Address record's two parts. The compiler chains the two Deconstruct calls automatically.

Named Tuple Patterns

A tuple with named elements supports a named positional pattern, where each sub-pattern is labeled by the tuple field name. This makes the pattern self-documenting and shields it from positional ordering changes.

Both syntaxes work for named tuples. The brace form { Status: "Paid", Amount: > 100m } is a property pattern. The parenthesized form (Status: "Paid", Amount: > 100m) is a positional pattern with field names attached. Functionally they're equivalent for tuples.

The difference matters for refactoring resilience. A pattern that uses positional ordering (("Paid", > 100m)) breaks silently if the tuple's element order changes. A pattern that uses names ((Status: "Paid", Amount: > 100m)) keeps working as long as the field names stay the same.

After the refactor, summary is ("Paid", > 100m) is a compile error (string can't satisfy a relational pattern against decimal). That's good; the compiler catches it. But if the types happen to be compatible (two strings, for example), the refactor would silently invert the meaning. Named patterns avoid that risk entirely.

For records and classes with Deconstruct, the named positional pattern form is also supported when the Deconstruct parameters have names that match.

The names here come from the record's positional declaration, which propagates them through the auto-generated Deconstruct. Same advantage as with tuples: the pattern keeps its meaning even if someone reorders the record parameters, because the compiler matches on name rather than position.

Named positional patterns compile to the same IL as unnamed ones. The names are checked at compile time and erased before lowering, so there's no runtime difference. Use them freely.

Discards Inside Positional Patterns

A discard (_) is a pattern that matches anything without binding the value. Inside a positional pattern, it's how you say "I don't care about this slot."

Each arm ignores the slots it doesn't care about. The first arm cares only about status. The second cares only about amount. The third cares only about currency. Discards make the intent obvious; without them you'd need a var pattern for each unused slot and then ignore the bound variable, which is noisier.

Discards in positional patterns are distinct from discards in deconstruction-like assignments. In a pattern, _ is a sub-pattern that succeeds for any value. In a deconstruction (var (_, amount) = order;) it's a discard that throws away the assigned value. The mechanics are different but the spirit is the same: "I don't need this."

You can also use discards to make a positional pattern serve as a pure type check.

EmailNotification(_, _) matches any EmailNotification value, regardless of its parts. The same thing could be written as obj is EmailNotification, which is shorter. The positional form is mostly useful when you're combining it with other constraints in a switch expression that already uses positional matching elsewhere; keeping the syntax consistent across arms is sometimes worth the extra characters.

Common Mistakes

A few recurring mistakes show up in positional pattern code. They all share a root cause: the patterns rely on position, and position is invisible at the call site, which means refactors can silently change behavior.

The parameter order in `Deconstruct` is part of the public API. A two-parameter Deconstruct(out string status, out decimal amount) and Deconstruct(out decimal amount, out string status) have the same signature in C# (both are void with two out slots of different types), so the compiler doesn't see a difference. But every positional pattern in the codebase that says is ("Paid", > 100m) is encoded against the original order. If you flip the parameters, the patterns either stop compiling (if the types differ enough that the sub-pattern types are wrong) or, worse, keep compiling but match the wrong things.

The compiler will catch this particular case because the types disagree. Where it gets dangerous is when the types happen to match, for example, two strings or two ints. Then the swap is invisible.

Forgetting that record parameters are the `Deconstruct` order. A positional record's Deconstruct order is exactly the order of parameters in the record header. Reordering the header for any reason (style, alphabetization, refactoring tool suggestion) rewrites the Deconstruct and silently changes the meaning of every positional pattern that targets the record.

The fix is to prefer property patterns or named positional patterns when you care about refactor safety. alice is { Tier: "Premium", Country: "US" } is immune to record parameter ordering.

Arity mismatch is a compile error, not a runtime error. A pattern with three sub-patterns won't match a Deconstruct with two out parameters. The compiler error (CS8129) is clear enough once you know what to look for, but it sometimes surprises developers who expect runtime flexibility.

If you want flexible arity, define overloads of Deconstruct for each arity you want to support, the way the multi-overload section earlier showed.

Conflicting overloads at the same arity. Two Deconstruct overloads with the same number of out parameters but different parameter types compile, but positional patterns can't pick between them. The error is at the call site, not at the Deconstruct declarations, which makes it confusing.

The fix is to drop one of the overloads or rename one (it doesn't have to be called Deconstruct; renamed overloads are just regular methods that the pattern won't consider).

Forgetting that `null` short-circuits. A positional pattern against a reference type that could be null doesn't throw; it simply doesn't match. That's good behavior, but it sometimes hides logic bugs.

null is "not paid", which is technically correct but might not be what the code intended. If you want to distinguish "wasn't paid" from "didn't exist," add an explicit null pattern or check first.

That's the explicit null arm doing the work the positional pattern alone couldn't. Putting null first ensures it's matched before any pattern that would otherwise treat it as "not matching."

Quiz

Positional Patterns Quiz

10 quizzes