Pattern matching combines runtime type tests, data-shape tests, and value extraction in a pattern used by is, switch statements, and switch expressions.
A switch chooses the first arm in text order whose pattern matches and whose guard is true; a broad pattern can hide later intent, and a type pattern never matches null.
Put narrow, specific patterns before general ones, and deliberately handle boundaries such as null, unknown types, undefined enum values, and empty lists.
What it is and why it exists
Pattern matching tests an input against a pattern and can extract values that later code needs when the test succeeds. It can test more than one constant: runtime types, object properties, deconstructed positions, numeric relationships, and sequence shapes all fit the model. The first rule to remember is that a pattern describes which values belong to a branch; variables declared by it are usable only after a successful match.
Without pattern matching, code that handles object or several message types often tests with is, casts, and then adds property conditions. A declaration pattern combines those actions: value is Order order tests the type and binds order. The compiler knows that the variable is available only on control-flow paths where the match succeeded, so subsequent member access stays statically type-safe.
Patterns appear in three main constructs. An is expression answers a Boolean question, a switch statement runs statements for the selected case, and a switch expression produces a value. A switch expression is often the most compact choice when one input computes one result, but it is still an ordered branch structure with a failure path.
You encounter patterns when parsing boundary objects, routing messages by record shape, expressing state transitions, and inspecting short command sequences. They work on input whose structure is already represented by static types; they do not replace validation of external data. JSON, HTTP, or database values still need parsing, range checks, and authorization before patterns can safely organize the values you obtained.
Pattern matching is not a replacement for polymorphism either. If each derived type owns its behavior, a virtual method or interface often preserves encapsulation better than repeated type switches. Patterns fit cases where the caller genuinely owns the classification rule or where several values must jointly select the result.
How it works
Input, pattern, and result
Every match has an input value and a pattern. A constant pattern compares a particular value; declaration and type patterns inspect the runtime type; relational patterns use <, <=, >, and >=; logical patterns combine other patterns with not, and, and or. A var pattern always matches and binds the input, while the discard pattern _ handles an unbound catch-all arm in a switch expression.
A type pattern succeeds only when the input is non-null and its runtime type is pattern-compatible with the target type. value is string text therefore establishes two facts: value is a string, and text is not null. Use the constant pattern null to test for null, or is not null to test for a non-null value.
Pattern variables participate in definite-assignment analysis. The true branch of if (value is string text) can read text; the false branch cannot assume it was bound. If you store the match result in a separate Boolean or place it in a complex expression, the variable’s usable scope may be narrower than it looks; trust the compiler diagnostic instead of guessing.
Recursive patterns read data shapes
A property pattern passes readable properties or fields to nested patterns. order is { Total: > 0m, Customer: not null } requires a non-null input and makes the two members satisfy a relational and a non-null pattern. An extended property pattern can shorten { Address: { City: "Paris" } } to { Address.City: "Paris" }; both fail to match if an intermediate receiver is null.
A positional pattern reads tuple elements directly or invokes an available Deconstruct method, then passes the results to subpatterns. Position is part of the contract, so (var width, var height) gets its meaning from tuple positions or Deconstruct parameter order, not the variable names. When those positions are not obvious, a property pattern is usually easier to review.
A list pattern tests sequence elements by position. Its input type must be both countable and indexable; an arbitrary IEnumerable<T> is not enough. [first, second] matches exactly two elements, [first, .., last] permits zero or more middle elements, and a slice with a nested pattern also requires a sliceable type.
Arms are selected in order
A switch expression checks arms from top to bottom and returns the result of the first arm whose pattern matches and whose optional guard is true. A case guard follows when and handles Boolean conditions that pattern syntax cannot express, such as a domain-method call. If a guard is false, matching continues with later arms.
Order therefore encodes priority. { Total: >= 1000m } must appear before { Total: > 0m }, or the broader positive-total arm already covers large orders. The compiler reports a later pattern as an error when it can statically prove the arm is unreachable, but it cannot prove arbitrary business relationships between methods used in when guards.
Logical-pattern precedence is not, then and, then or. value is not null and Order follows that rule, though parentheses make reviews easier when several operators meet. Runtime checking order among subpatterns with the same binding precedence is undefined, and a failed match may skip the remaining subpatterns, so property getters or Deconstruct methods with side effects make the code hard to reason about.
Boundaries of common patterns
| Pattern | Example | Important boundary |
|---|---|---|
| Constant | value is 0 | null is also a constant pattern |
| Declaration | value is Order order | Never matches null |
| Property | { Total: > 0m } | The input and nested receivers must follow a non-null path |
| Positional | (0, var y) | Depends on tuple positions or Deconstruct order |
| Relational | >= 0 and <= 100 | The constant must convert to the input type |
| Logical | not (null or "") | Precedence is not, and, or |
| List | ["run", var file] | Requires counting and indexing, not just enumeration |
| Discard | _ | Matches every remaining input in a switch expression |
Examples
These four examples progress from classifying one value to combining properties, guards, positions, and list patterns. This environment has no .NET SDK or other C# compiler, so every block is explicitly marked unexecuted and no output is fabricated.
Testing type and properties together
The message classifier begins at an object? boundary. Narrow arms distinguish valid and invalid orders before handling non-empty strings, null, and unknown types.
// # not executed here: the .NET SDK and C# compilers are unavailable
using System;
object?[] messages =
[
new OrderPlaced("A-17", 125m),
new OrderPlaced("A-18", 0m),
"heartbeat",
null
];
foreach (object? message in messages)
Console.WriteLine(Describe(message));
static string Describe(object? message) => message switch
{
OrderPlaced { Total: > 0m } order => $"order {order.Id}: {order.Total:C}",
OrderPlaced => "invalid order",
string { Length: > 0 } text => $"signal: {text}",
null => "missing message",
_ => "unsupported message"
};
public sealed record OrderPlaced(string Id, decimal Total);# not executed here: the .NET SDK and C# compilers are unavailableOrderPlaced { Total: > 0m } order tests the type and property before binding the complete object to order. The second OrderPlaced arm catches zero and negative totals; putting it first would completely hide the specific arm and cause a compile-time error.
The final _ gives the method a result for every object?. This example returns diagnostic text. A real boundary might instead throw a domain exception or return a result type, but that failure contract must be explicit rather than allowing an unknown message down the normal path.
Adding a domain condition with a guard
Relational patterns directly express the price bands, while a method decides whether the date is a business day. The corresponding when runs only after its property pattern matches.
// # not executed here: the .NET SDK and C# compilers are unavailable
using System;
Shipment[] shipments =
[
new("FR", 8m, true),
new("FR", 8m, false),
new("US", 25m, true)
];
DateTime acceptedAt = new(2026, 9, 4);
foreach (Shipment shipment in shipments)
Console.WriteLine(SelectLane(shipment, acceptedAt));
static string SelectLane(Shipment shipment, DateTime acceptedAt) => shipment switch
{
{ WeightKg: <= 0m } => "reject",
{ Country: "FR", Express: true, WeightKg: <= 20m }
when IsBusinessDay(acceptedAt) => "express-fr",
{ Country: "FR", WeightKg: <= 20m } => "standard-fr",
{ WeightKg: > 20m and <= 30m } => "freight",
_ => "manual-review"
};
static bool IsBusinessDay(DateTime value) =>
value.DayOfWeek is not (DayOfWeek.Saturday or DayOfWeek.Sunday);
public sealed record Shipment(string Country, decimal WeightKg, bool Express);# not executed here: the .NET SDK and C# compilers are unavailablePutting IsBusinessDay in a guard preserves a method call that the pattern itself cannot express. A condition such as WeightKg > 20m && WeightKg <= 30m already fits relational and logical patterns; it does not need a captured variable and an extra when.
The first arm rejects non-positive weight before any general arm assigns a shipping lane to invalid data. Guard methods should be side-effect free. Future arm reordering or broader coverage tests should not let a change in invocation count alter system state.
Expressing state transitions with positions
A tuple pattern fits a result jointly determined by two inputs. A property pattern for the command sits in the second position, so amount validation and current state live in one transition table.
// # not executed here: the .NET SDK and C# compilers are unavailable
using System;
OrderState state = OrderState.Created;
state = Next(state, new Pay(42m));
Console.WriteLine(state);
state = Next(state, new Ship("ZX-9"));
Console.WriteLine(state);
static OrderState Next(OrderState state, OrderCommand command) =>
(state, command) switch
{
(OrderState.Created, Pay { Amount: > 0m }) => OrderState.Paid,
(OrderState.Paid, Ship { TrackingId.Length: > 0 }) => OrderState.Shipped,
(OrderState.Created or OrderState.Paid, Cancel) => OrderState.Cancelled,
(OrderState.Shipped or OrderState.Cancelled, _) => state,
_ => throw new InvalidOperationException(
$"Invalid transition: {state} + {command.GetType().Name}")
};
enum OrderState { Created, Paid, Shipped, Cancelled }
abstract record OrderCommand;
sealed record Pay(decimal Amount) : OrderCommand;
sealed record Ship(string TrackingId) : OrderCommand;
sealed record Cancel : OrderCommand;# not executed here: the .NET SDK and C# compilers are unavailableThe tuple’s positions represent the state and command type, while the nested property pattern checks command content. If callers can pass a null command, the signature should say OrderCommand? and add an explicit arm rather than relying on a null dereference in the catch-all error message.
The terminal-state arm deliberately keeps the old state; every other invalid transition throws. Those behaviors are different business contracts. Do not swallow every unknown combination with _ => state merely to make the switch look exhaustive.
Parsing a short command with a list pattern
A list pattern fits inputs with few elements whose positions have meaning. The slice .. var tags captures the remaining array, and the guard requires at least one tag.
// # not executed here: the .NET SDK and C# compilers are unavailable
using System;
string[][] commands =
[
[],
["ship", "A-17"],
["ship", "A-18", "--priority"],
["tag", "A-19", "fragile", "gift"]
];
foreach (string[] command in commands)
Console.WriteLine(Parse(command));
static string Parse(string[] args) => args switch
{
[] => "help",
["ship", var orderId] => $"ship {orderId}",
["ship", var orderId, "--priority"] => $"priority {orderId}",
["tag", var orderId, .. var tags] when tags.Length > 0 =>
$"tag {orderId}: {string.Join(',', tags)}",
[var verb, ..] => $"unknown command: {verb}"
};# not executed here: the .NET SDK and C# compilers are unavailable["ship", var orderId] does not match a three-element command, so the priority arm remains reachable. The final pattern requires at least one element. Because the first arm handles an empty array, no input shape is omitted here.
The method accepts an array, not IEnumerable<string>. If input arrives as a lazy stream, decide whether it may be materialized and impose a maximum length first; a list pattern is not a tool for searching the prefix of an infinite sequence.
Pitfalls
Putting a broad pattern before a narrow one
Fix: order arms by set inclusion: constants and narrow ranges first, general types and broad ranges later, then an explicit catch-all such as null or _. Add separate boundary tests for guarded arms because the compiler cannot infer relationships between arbitrary domain predicates.
Confusing and with or
Fix: use >= 18 and < 65 for one contiguous interval and reserve or for disjoint sets. Make a truth table for the value before the lower bound, both bounds, and the value after the upper bound; do not test only an ordinary value inside the range.
Treating an exhaustiveness warning as a proof
Fix: choose the final arm from the contract. If boundary input can be unknown, use _ to return an explicit error or throw your own exception. If a closed domain should force review when a case is added, promote the warning to an error in the build and still test null and cast enum values.
Treating a property pattern as a side-effect-free validator
Fix: keep properties used by patterns cheap, stable, and side-effect free. Parse external input into a plain data object before matching its shape. Run an expensive or fallible computation once into a local value, then branch on that value.
Assuming list patterns work on every sequence
Fix: use list patterns with arrays, strings, spans, or custom types that explicitly provide counting and indexing. Use single-pass iteration for a general stream. If shape matching really requires materialization, impose a size limit before building the target container.
Swallowing domain errors in a catch-all arm
Fix: separate an expected no-op from genuinely unknown input. Give the former a named arm and make the latter return a failure or throw a domain exception. Tests for new derived types or enum members should prove they cannot fall into a successful catch-all.
Arm coverage and runtime boundaries
Subsumption and guards
If every value matched by pattern P is already covered by the preceding set of unguarded patterns, that set subsumes P and the later arm cannot run. C# reports the condition as a compile-time error. A constant arm after a type arm and a narrow range after a broad range are common examples.
Guards limit what static analysis can establish. If an earlier arm has a when, its broad pattern may match while the guard is false, leaving a later occurrence of the same pattern reachable. Two method calls that are mutually exclusive by business rules are still arbitrary Boolean expressions to the compiler, so tests must prove their order and coverage.
switch statements and expressions both use patterns, but their result contracts differ. An expression must produce an arm value convertible to a common result type, while a statement arm runs control flow. Do not move a branch with several side effects into a helper merely to turn it into an expression; first confirm that computing a value is the real contract.
Exhaustiveness and failure modes
A pattern set is exhaustive only if some pattern applies to every possible input. _ matches all remaining inputs in a switch expression and is the simplest syntactic catch-all. var remaining also matches everything and binds the value when the error message genuinely needs it.
When a nonexhaustive switch expression sees an unmatched value, modern .NET throws System.Runtime.CompilerServices.SwitchExpressionException. The compiler usually warns, but a warning is not runtime protection and does not prove that future values get correct business behavior. The project should explicitly decide whether its build promotes this warning to an error.
Enums particularly require a distinction between declared members and all values of the underlying integer type. Deserialization, casts, and version skew can produce an undefined number. If _ returns a normal result, callers may never learn that the protocol drifted; without _, you must accept and test the runtime-failure contract.
List patterns currently receive no exhaustiveness warning for missing sequence shapes. Even after [], [var one], and [var first, var second], an array of length three may find no arm at runtime. Use a slice or catch-all when input may have any length.
Null and recursive failure
Declaration, type, property, and positional patterns do not accept null as a successful non-null object match. input is { } value uses an empty property pattern to test non-null and bind the value, though is not null is often more direct. A var value pattern does match null, so it is not proof of non-nullness.
If any receiver in a nested property path is null, that property pattern fails rather than throwing a null-reference exception. { Customer.Address.City: "Paris" } therefore implies a non-null path through Customer and Address. This convenience is not complete validation; fields absent from the pattern may still be invalid.
A pattern variable’s static type comes from the pattern. With an object? input, text in a string text arm is a non-null string, while other in a final var other arm remains object?. When reviewing generated code, check whether a downstream API receives the narrowed variable or whether the code keeps using the original broad input.
Property reads and deconstruction calls
A successful property pattern reads the named fields or properties and passes those values to subpatterns. The specification does not guarantee a fixed order among subpatterns, nor require the remaining ones to run after failure. Depending on getter order, count, or side effects ties correctness to behavior that the language does not guarantee.
A positional pattern reads tuple elements directly. For a deconstructable type, it selects and invokes an appropriate Deconstruct method. The number of out parameters must match the number of positions, and parameter order decides which value each subpattern receives. Side effects in Deconstruct likewise make a seemingly pure classification mutate state.
A property pattern repeats member names but avoids unclear positional meaning. If two record fields have the same type or constructor order conflicts with reader intuition, { Start: 0, End: var end } is usually safer than (0, var end). Positional patterns fit stable, familiar structures such as coordinates or state-command pairs.
The list-pattern structural protocol
A type compatible with a list pattern must be countable and indexable. The compiler uses an accessible Length or Count and reads elements through an Index indexer or an indexer with one int parameter. A type that provides only IEnumerable<T> cannot meet these static requirements because enumeration supplies neither random positions nor length.
An unbound .. affects the length and placement of other indexes without constructing a middle slice. .. var middle must produce and bind that slice, so the input must also support a Range indexer or an appropriate Slice method; the language specifies handling for arrays and strings. Do not capture the middle when you only intend to ignore it.
[head, .., tail] needs at least two elements and tests their first and last positions. It is not a search: ["error", ..] checks only the first element, and [.., "error"] only the last. Search arbitrary positions with collection APIs or LINQ, considering enumeration count separately.
A custom type can opt into list patterns through the corresponding members, but that brings their semantics into a language construct. Count must stably describe the number of indexable elements, and the indexer must agree with it. A false length, expensive indexing, or mutation during access makes ordinary patterns surprising.
Patterns do not normalize data
A pattern classifies the current value; it does not trim text, fold case, convert units, or parse with a culture. String constant patterns are case-sensitive, so "vip" does not automatically match "VIP". Perform the normalization required at a boundary once before matching.
A guard can call a comparer or domain predicate, but that hides the policy inside a branch condition. When several arms need the same normalized result, store it in a local before matching. That avoids duplicate computation and gives every arm the same policy instead of letting them choose different cultures or string comparisons.
Patterns also do not establish cross-field invariants you did not write. { Start: >= 0, End: >= 0 } does not prove Start <= End; put that relationship in a guard or establish it in the constructor first. Patterns consume invariants clearly, while data types and boundary validation establish them.
If the original value is needed for auditing or diagnostics, store the normalized result in a new variable rather than overwriting the input. The pattern then gets one representation while the program retains what actually arrived at the boundary.
Testing a pattern set
Pattern tests should cover branch boundaries, not just one happy path per arm. For a relational range, test every boundary and its neighbors. For a type classification, test a base class, derived class, unknown implementation, and null; for a list classification, test one element fewer, exactly enough, and one more.
An ordering test needs an input that satisfies more than one pattern. A large VIP order also satisfies a general positive-total arm, so only that overlap proves the specific arm wins. If each test input matches only one arm, swapping the arms leaves the test green.
Guard tests should control time, culture, and service results. The example accepts a date argument instead of reading DateTime.Now inside the guard, so weekend and weekday cases are deterministic. If a complex guard has several dependencies, compute one named domain fact first and let the matching structure consume it.
For an intentionally nonexhaustive design, test the failure path too. Cast an undefined number to an enum, pass an unknown derived type, and build an array whose length has no arm. Besides successful assertions, verify the exception type or error result so _ cannot silently turn failure into an ordinary value.
Further reading
5 questions · 2 predict-the-output · 1 spot-the-bug