← Open in the full interactive course (progress tracking, search & more)

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

Positional pattern

Nested property pattern

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

LIST PATTERN ANATOMY
[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

BUILDING UP TO RECURSIVE PATTERNS — STEP BY STEP
1. LIST PATTERNS — MATCHING A SEQUENCE'S SHAPE
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 */ }
2. POSITIONAL PATTERNS — MATCHING VIA Deconstruct
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 */ }
3. NESTED / RECURSIVE PROPERTY PATTERNS — REACHING DEEPER
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
};
4. COMBINING EVERYTHING — A REALISTIC COMPOSITE PATTERN
bool matches = order is
    { Customer.Address.Country: "US" or "CA", Total: > 0 and <= 5000 }
    and not { Status: "Cancelled" };

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

Checking a shipment by its packing list

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

HOW THE COMPILER TURNS A PATTERN SWITCH INTO EFFICIENT CODE
1. IT IS NOT ALWAYS A SEQUENTIAL IF-CHAIN
2. THE COMPILER BUILDS A DECISION TREE FROM YOUR ARMS
3. WHERE ORDER STILL MATTERS

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?

Mental Model

List pattern = "does this sequence have this shape?" (length + ends)
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


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.


dotnetmadeeasy.com — Learn C# and .NET, the right way.