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.
[1, 2, 3] — match an exact sequence[first, .., last] — match specific ends, ignore the middle[var head, .. var rest] — capture a slicePoint(0, 0) — match by Deconstruct, not property names{ Customer.Address.Country: "US" } — reach through several levels?. null-conditional checksA 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.
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 */ }
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.
[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
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 */ }
record auto-generates a Deconstruct method — any type with a matching Deconstruct (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: if order.Customer or order.Customer.Address is 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" };
or, and not all compose freely — the pattern reads as one connected rule instead of a maze of nested conditionals.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.
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.
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.
switch with 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.switch statements 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.value.SomeProperty freshly for every single arm that mentions it.when clause (a guard) can't be folded into the same optimized dispatch as a plain pattern, because it runs arbitrary code — arms with when clauses are evaluated in order, after any structural narrowing the compiler could still apply ahead of them...) 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.
Deconstruct — they don't work on arbitrary typesobj 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.
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.
.. 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.
[] 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.
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.
switch where several shapes are being routed at once.?. checks whenever you're testing a value several levels down a null-safe object graph.Deconstructed pieces look like this?"[1, 2, .. var rest]) match a sequence's shape directly, with at most one slice (..) per pattern.Point(0, 0)) match via a type's Deconstruct method — automatic for records, and freely combine with property patterns.Customer.Address.Country: "US") reach multiple levels deep in one expression, remaining null-safe throughout.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];?
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#?
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?
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?
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.