C# Pattern Matching in Depth: List, Slice and Positional
You've matched types, properties, and ranges. Now match the shape of a whole list, tear a record apart positionally, and understand why the compiler doesn't just run your patterns top to bottom.
You already know is Circle { Radius: > 10 } and the and/or/not combinators from Foundations. Those cover "what type is this, and what do its properties look like." But a lot of real data doesn't live in named properties — it lives in sequences. Given int[] scores, how do you express "the first score is 100, and I want everything after it" without slicing and indexing by hand? And given a record Point(int X, int Y), is there a way to match and destructure it in a single expression, without naming X and Y as property patterns at all?
There's also a question worth answering properly now that you've seen a few dozen switch expressions: does a switch with ten pattern arms really check each one sequentially, like an if/else chain in disguise? The honest answer is "usually not," and understanding why matters for writing pattern-based code you trust at scale.
In this lesson: list patterns, positional patterns via Deconstruct, nested/recursive property patterns, richer combinator usage, and a clear, correct picture of how the compiler actually executes a pattern-matching switch.
What Is It?
Three Pattern Kinds This Lesson Adds
List pattern
[1, 2, 3]— match an exact sequence[first, .., last]— match specific ends, ignore the middle[var head, .. var rest]— capture a slice
Positional pattern
Point(0, 0)— match byDeconstruct, not property names- Works automatically for any positional record
- Combines naturally with property patterns
Nested property pattern
{ Customer.Address.Country: "US" }— reach through several levels- Each level is still null-safe
- Replaces a chain of
?.null-conditional checks
The Technical Definition
A list pattern (C# 11) matches a value implementing an indexer and a countable Length/Count (arrays, List<T>, and similar) against a sequence shape, optionally using a slice pattern (..) to match a variable-length gap. A positional pattern matches a value against a parenthesized list of sub-patterns by calling the value's Deconstruct method (or, for a tuple, its built-in deconstruction) and matching each sub-pattern against the corresponding output. These compose with everything from the previous pattern-matching lesson — type patterns, property patterns, relational patterns, and and/or/not — inside is expressions and switch.
Why Does It Exist?
The Problem
Before list patterns, expressing "this array starts with a specific value and has at least one more element" meant manual length checks and indexing — easy to get off-by-one wrong, and it obscures the actual rule being expressed:
bool StartsWithZeroAndHasMore(int[] scores) =>
scores.Length >= 2 && scores[0] == 0;
And before positional patterns, matching a record's data meant repeating property names even when the record's whole reason for existing is that its shape (its constructor parameter order) already tells you what each value means:
// Verbose — repeats property names that the record's shape already implies
if (point is Point { X: 0, Y: 0 }) { /* origin */ }
The Solution
bool StartsWithZeroAndHasMore(int[] scores) => scores is [0, ..];
if (point is (0, 0)) { /* origin — positional, matches via Deconstruct */ }
Both read as a direct description of the shape being tested, exactly the same win the previous lesson's patterns already gave you for types and properties — now extended to sequences and to a record's positional shape.
Big Picture
[1, 2, 3] — exactly these three elements, nothing more or less[1, ..] — starts with 1, any length after[.., 9] — ends with 9, any length before[first, .. , last] — capture first and last, ignore the middle[var head, .. var rest] — capture the first element AND the remaining slice as an array
How It Works
int[] scores = [95, 88, 72, 100];
if (scores is [var first, .. var rest])
{
// first == 95, rest == [88, 72, 100] (a new array)
}
if (scores is [_, _, _, 100]) { /* exactly 4 elements, ending in 100 */ }
public record Point(int X, int Y);
Point p = new(3, 0);
if (p is (var x, 0)) { /* Y is exactly 0; x captures X */ }
if (p is (0, 0)) { /* the origin, tested positionally */ }
- This works because
recordauto-generates aDeconstructmethod — any type with a matchingDeconstruct(hand-written or generated) supports positional patterns, not just records.
public record Address(string Country);
public record Customer(string Name, Address Address);
public record Order(Customer Customer, decimal Total);
bool isBigUsOrder = order is
{
Total: > 1000,
Customer.Address.Country: "US" // reaches THREE levels deep in one pattern
};
Customer.Address.Country: "US"is shorthand for a full nested property pattern —Customer: { Address: { Country: "US" } }— and it's still null-safe at every level: iforder.Customerororder.Customer.Addressis null, the whole pattern simply fails to match, no exception.
bool matches = order is
{ Customer.Address.Country: "US" or "CA", Total: > 0 and <= 5000 }
and not { Status: "Cancelled" };
- Nested property patterns, relational ranges,
or, andnotall compose freely — the pattern reads as one connected rule instead of a maze of nested conditionals.
Simple Example
string Describe(int[] numbers) => numbers switch
{
[] => "empty",
[var only] => $"single value: {only}",
[var first, var second] => $"pair: {first}, {second}",
[var first, .., var last] => $"starts with {first}, ends with {last}",
};
Describe([]); // "empty"
Describe([7]); // "single value: 7"
Describe([1, 2]); // "pair: 1, 2"
Describe([1, 2, 3, 4]);// "starts with 1, ends with 4"
Code → Meaning → Result: Each arm describes a distinct shape of the array — empty, one element, exactly two, or "at least two, and I only care about the ends" — and the compiler picks the first arm whose shape actually matches. No manual length checks or indexing anywhere in this code.
Real-World Example
A simple command-line-argument router is a natural fit: the "shape" of the arguments genuinely determines what the command means.
string RunCommand(string[] args) => args switch
{
["deploy", var environment] => $"Deploying to {environment}",
["deploy", var environment, "--force"] => $"Force-deploying to {environment}",
["rollback", .. var targets] when targets.Length > 0
=> $"Rolling back: {string.Join(", ", targets)}",
["help"] or [] => "Usage: deploy <env> | rollback <targets...>",
_ => "Unknown command",
};
RunCommand(["deploy", "staging"]); // "Deploying to staging"
RunCommand(["deploy", "prod", "--force"]); // "Force-deploying to prod"
RunCommand(["rollback", "v1.2", "v1.3"]); // "Rolling back: v1.2, v1.3"
RunCommand([]); // "Usage: ..."
And a domain example tying positional patterns back to records — routing a shipping decision by an order's shape:
public record ShippingRequest(string Destination, double WeightKg, bool IsPriority);
decimal Cost(ShippingRequest r) => r switch
{
(_, _, IsPriority: true) => 40m,
("Domestic", <= 5, _) => 5m,
("Domestic", _, _) => 12m,
("International", > 20, _) => 75m,
("International", _, _) => 35m,
_ => 15m,
};
Mixing a positional pattern with a named property (IsPriority: true) in the same pattern is entirely legal — positional and property patterns compose freely on the same value.
Analogy
A property pattern is like checking a shipment by reading labeled fields on a manifest: "Weight: over 20, Destination: International." A positional pattern is like checking the same shipment by its packing order instead — "first item is the invoice, second is the product, I don't care what the third is" — using the position things come in rather than a label on each one. Both describe the same box; they're just two different ways of asking "does this look like what I expect?"
A list pattern is checking the whole pallet at once: "the first crate is fragile, and I don't care how many more crates follow" ([Fragile, ..]), rather than counting crates one at a time and checking each individually.
Under the Hood
- A naive mental model treats a
switchwith pattern arms as "check arm 1, if it fails check arm 2, and so on" — literally the same cost as an if/else-if chain. The compiler is smarter than that whenever the arm shapes allow it. - When multiple arms test the value's type first, the compiler groups and orders those type checks efficiently — closer to a type-dispatch table than a linear scan — rather than re-testing the same runtime type repeatedly across arms.
- When arms test a constant value of a switchable type (strings, integers, enums), the compiler can emit an actual jump table or a hash-based dispatch, exactly as it always has for ordinary (non-pattern)
switchstatements on those types — a long chain of"deploy" or "rollback" or "help"string arms is not evaluated one string-comparison at a time in the worst case.
- Conceptually, the compiler analyzes all the arms together and builds an efficient decision structure — testing the properties/positions that best discriminate between arms first, and reusing a test's result across multiple arms that share it, rather than re-evaluating
value.SomePropertyfreshly for every single arm that mentions it. - This is a genuine optimization the language guarantees you don't have to hand-roll — writing ten pattern arms is not automatically ten times slower than a hand-tuned decision tree; the compiler is doing meaningful work to avoid that.
- Arm order still matters for correctness whenever arms overlap — the first matching arm wins, exactly like an if/else chain would. The compiler's internal reordering for efficiency never changes which arm you observe winning; it only changes how cheaply it gets there. If two arms could both match the same value, put the more specific one first, same as always.
- A
whenclause (a guard) can't be folded into the same optimized dispatch as a plain pattern, because it runs arbitrary code — arms withwhenclauses are evaluated in order, after any structural narrowing the compiler could still apply ahead of them.
Common Confusion
1. A slice pattern (..) captures an independent array, not a "view" into the original
[var first, .. var rest] allocates a genuinely new array containing the remaining elements (for an array input) — it's not a lightweight window over the same memory. For very large sequences matched frequently, that's a real allocation to be aware of, not a free slice.
2. Positional patterns need a Deconstruct — they don't work on arbitrary types
obj is (var a, var b) only compiles if obj's type has a matching two-output Deconstruct method (or is a tuple). It's not a generic "grab the first two things" syntax — it's tied directly to deconstruction, the same mechanism var (x, y) = point; already uses.
3. "The compiler reorders my arms for efficiency" doesn't mean "arm order is irrelevant"
The compiler is free to change the internal execution strategy (which tests it runs, and in what order, under the hood) as long as the observable result — which arm wins for a given input — matches what sequential evaluation would produce. It will never silently pick a later arm over an earlier one that also matches. Order still fully controls correctness when arms overlap; it just doesn't dictate the literal mechanics of how each arm gets checked.
Common Mistakes
Mistake 1 — Using two .. in one list pattern
// compile error — a list pattern allows at most ONE slice (..)
if (scores is [.., var middle, ..]) { }
A slice pattern can appear at most once per list pattern — it's meant to soak up "everything else in the middle," and two of them would be ambiguous about where each slice's boundary is.
Mistake 2 — Forgetting that [] only matches exactly zero elements
Expecting [] to be a catch-all "anything" pattern. It specifically means "a sequence with zero elements" — the opposite of a wildcard.
Use _ for "match anything, don't care what," and reserve [] specifically for the empty-sequence case, as shown in the Simple Example above.
Mistake 3 — Assuming positional and property patterns are mutually exclusive
Believing you have to choose one style per match. As shown in the shipping example, r is (_, _, IsPriority: true) freely mixes positional matching with a named property pattern in the same expression — use whichever reads more clearly for each piece of the match.
When Should I Use It?
- Use list patterns when a sequence's shape (length, specific ends) is genuinely the thing you're testing — command parsing, validating a fixed-format array, recognizing edge/base cases (empty, single element) in recursive algorithms.
- Use positional patterns for records and tuples where the position already carries clear meaning — coordinates, RGB triples, small DTOs — especially inside a
switchwhere several shapes are being routed at once. - Use nested property patterns instead of a chain of
?.checks whenever you're testing a value several levels down a null-safe object graph. - Don't reach for list patterns on large, frequently-matched sequences without being aware of the slice-allocation cost mentioned above — plain indexing may be more appropriate in a genuine hot path.
Mental Model
Positional pattern = "does this thing's
Deconstructed pieces look like this?"Nested property pattern = "does this null-safe chain of properties look like this?"
The switch compiler = builds a real decision structure from all your arms together — not a naive top-to-bottom if-chain — while still guaranteeing "first matching arm wins" for correctness.
Remember: every pattern in this lesson still compiles down to ordinary type checks, property reads, indexer calls, and comparisons — richer syntax, same underlying mechanics as the previous lesson.
Key Takeaway
- List patterns (
[1, 2, .. var rest]) match a sequence's shape directly, with at most one slice (..) per pattern. - Positional patterns (
Point(0, 0)) match via a type'sDeconstructmethod — automatic for records, and freely combine with property patterns. - Nested property patterns (
Customer.Address.Country: "US") reach multiple levels deep in one expression, remaining null-safe throughout. - The compiler builds a genuinely efficient decision structure from a pattern switch's arms — type dispatch and jump tables where the arm shapes allow it — not a naive sequential if-chain, though "first matching arm wins" for correctness is always preserved.
- A slice pattern allocates a real new array for the captured remainder — be aware of that cost in hot paths over large sequences.
Check Your Understanding
You've seen list patterns, positional patterns, nested property patterns, and how the compiler actually executes a pattern switch. Let's check your understanding.
1. What does the pattern [var first, .. var rest] match against int[] scores = [10, 20, 30];?
Show answer
Correct: B
Why B is correct: The slice pattern (..) matches "everything else," and when captured with var rest, it produces a genuinely new array holding the remaining elements — as covered in "Common Confusion."
Why A is incorrect: The slice pattern accepts any remaining length, including zero — it's not restricted to exactly one extra element.
Why C is incorrect: This is exactly the common misconception this lesson calls out — rest is a new, independent array, not a window over the original.
Why D is incorrect: This pattern matches arrays of length one or more — one element for first, plus any number (including zero) captured by rest.
Reinforcement: A captured slice is a real allocation — worth remembering before using list patterns heavily on large, frequently-matched sequences.
2. Given public record Point(int X, int Y);, what makes point is (0, 0) valid C#?
Show answer
Correct: B
Why B is correct: Positional patterns work through a type's Deconstruct method. Records generate one automatically from their positional parameters, which is exactly what makes (0, 0) work here without any extra code.
Why A is incorrect: A type needs a matching Deconstruct method (or to be a tuple) to support positional patterns — it's not automatic for arbitrary types, as covered in "Common Confusion."
Why C is incorrect: List patterns (square brackets) and positional patterns (parentheses) are distinct pattern kinds with different syntax and different applicability (sequences vs. deconstructible types).
Why D is incorrect: The property names are irrelevant to a positional pattern — it works purely off the order Deconstruct outputs its values, which is why it's called "positional."
Reinforcement: Positional pattern support is tied directly to Deconstruct — the same mechanism behind var (x, y) = point;.
3. A switch expression has ten arms, each testing a different constant string value of the same input. Does the compiler necessarily evaluate them as ten sequential string comparisons in the worst case?
Show answer
Correct: B
Why B is correct: As explained in "Under the Hood," the compiler analyzes the whole set of arms and can generate efficient dispatch structures rather than a naive sequential chain, when the arm shapes (like constant values on a switchable type) allow it.
Why A is incorrect: This is the exact misconception this lesson corrects — a naive if-chain is not guaranteed, and often isn't what actually gets generated.
Why C is incorrect: String comparisons are not literally free; the point is that the compiler avoids doing ten of them in sequence when it can dispatch more directly instead.
Why D is incorrect: Arm declaration order affects which arm wins when arms overlap, not whether the compiler can optimize the underlying dispatch mechanism — alphabetical ordering has no bearing on this.
Reinforcement: The compiler's freedom to optimize dispatch never changes which arm wins for a given input — only how efficiently it gets there.
4. Which pattern correctly matches "an array with at least two elements, where I only care that the very first is 0" — without caring about length or any other element?
Show answer
Correct: A
Why A is correct: [0, ..] means "the first element is 0, followed by any number of additional elements (including zero more)" — exactly "starts with 0, don't care about the rest."
Why B is incorrect: [0] matches only an array with exactly one element, which is 0 — it rejects any array with more than one element, which isn't what was asked for.
Why C is incorrect: [] matches only a completely empty sequence — the opposite of what's being tested here.
Why D is incorrect: [.., 0] tests that the array ends with 0, not starts with it — the slice is positioned before the fixed element, not after.
Reinforcement: Where you place the fixed elements relative to .. in a list pattern determines whether you're anchoring to the start, the end, or both.
You can now express shape-based checks over sequences and records directly, and you understand what the compiler is really doing with a pattern-matching switch under the hood.