The full C# pattern language: every pattern kind with its version, how patterns compose, compiler exhaustiveness and subsumption checks, performance of the generated decision tree, and design trade-offs versus virtual methods.
C# patterns now cover most data checks. There are type, constant and null patterns, relational ones like >= 100, logical and, or and not combinators, and property patterns, including extended ones like { Customer.Address.State: "NSW" }. There are also positional patterns that use Deconstruct, var and discard patterns, and list patterns like [first, .., last]. The compiler turns a switch into an efficient decision tree, warns when it isn't exhaustive, and errors on unreachable cases. I use patterns for closed sets of data shapes and polymorphism when new types keep being added.
Pattern matching lets you test a value's shape and extract parts of it in one expression, in is, switch statements and switch expressions. C# 14 supports a rich set of patterns that compose with and, or, not. I'd walk through each kind, how the compiler checks exhaustiveness and subsumption, and when pattern matching beats polymorphism (and vice versa).
| Pattern | Example | Matches |
|---|---|---|
| Type / declaration | shape is Circle c |
Runtime type is Circle (and binds c) |
| Constant | status is OrderStatus.Paid, x is null, s is "" |
Equal to a constant |
| Relational (C# 9) | age is >= 18, temp is < 0m |
Comparison with a constant |
| Logical (C# 9) | x is > 0 and < 100, c is 'a' or 'e', obj is not null |
Combine patterns |
| Property | order is { Status: OrderStatus.Paid, Total: > 1000m } |
Properties match subpatterns (implies non-null) |
| Extended property (C# 10) | order is { Customer.Address.State: "NSW" } |
Nested properties with null checks |
| Positional | point is (0, 0), (a, b) is (> 0, > 0) |
Uses Deconstruct/tuples |
var |
GetValue() is var v |
Always matches, binds a variable (useful in guards) |
| Discard | _ (in switch expressions) |
Anything |
| List (C# 11) | args is ["deploy", var env, ..], bytes is [0xFF, 0xD8, ..] |
Sequence length and elements |
| Parenthesised | x is (> 0 and < 10) or 100 |
Grouping |
Type patterns can test interfaces and generic types; relational patterns work with numeric types, char, enums (via underlying values) and other constants.
decimal Shipping(Order order) => order switch
{
{ Total: >= 150m } => 0m,
{ Customer.Address.State: "NSW" or "VIC" or "ACT" } => 9.95m,
{ Customer.Address.State: "WA" or "NT" } => 19.95m,
{ Lines: [] } => throw new InvalidOperationException("Empty order"),
_ => 14.95m
};
if (message is OrderShipped { TrackingNumber: { Length: > 0 } tracking })
Notify(tracking);
The compiler builds a decision DAG from all arms:
SwitchExpressionException. For enums, the compiler requires a discard because any integer value is possible; for bool, both values must be covered; for nullable inputs, null must be handled.int before int n when n > 0, or > 0 before > 10.when guards are opaque to exhaustiveness analysis (the compiler can't reason about arbitrary boolean expressions).The classic expression problem:
| Polymorphism (virtual/interface) | Pattern matching (switch over types) | |
|---|---|---|
| Add a new type | Easy: new class implements the method | Hard: update every switch |
| Add a new operation | Hard: add a method to every class | Easy: write a new function with a switch |
| Behaviour location | Spread across types | Centralised in one place |
| Best for | Open hierarchies, plug-ins, extensibility | Closed sets: results, messages, ASTs, state machines, DTO mapping |
Pattern matching is better when the set of types is closed (e.g. PaymentResult.Approved/Declined/Failed), when the operation belongs to a different layer (mapping domain results to HTTP responses shouldn't live in domain classes), when conditions combine several properties (shipping rules), and for parsing/inspecting data.
Polymorphism is better when new types are added by different teams or plug-ins, when behaviour is intrinsic to the type, and when the same switch would be duplicated in many places.
A rules engine for shipping fees had nested if/else blocks across 200 lines checking states, totals, weights and member tiers; bugs appeared whenever a rule was added. We rewrote it as a switch expression with property and relational patterns ordered from most to least specific, with unit tests per arm. The compiler's subsumption errors immediately caught two rules that could never fire in the old code, and adding a new state became a one-line arm.
Checks breadth of modern C# and design judgement.
Green flags: names most pattern kinds with rough versions; extended property and list patterns; exhaustiveness and subsumption; is null; patterns versus polymorphism trade-off.
Red flags: only knows is Type t; ignores exhaustiveness; uses type switches everywhere.
The compiler lowers patterns into efficient code:
isinst + null check (one operation; the bound variable is the cast result).== for primitives, object.Equals for some cases; is null → reference check that ignores operator overloads).Length/Count check + indexer access ([^1] uses Index support) + Slice for captured slices.The compiler considers the input's domain (all values of the type, plus null for reference/nullable types) and checks whether the union of arms (without when clauses) covers it. For numeric relational patterns it reasons about ranges; for enums, named members don't make it exhaustive (other values are possible); for closed sets of types it can't know a hierarchy is closed, so a discard is needed.
Property patterns { } imply not-null (x is { } nonNull is a common non-null test). Nullable flow analysis understands patterns: after if (o is { Customer: { } c }), c is non-null. Type patterns never match null.
List patterns require countable (Length/Count) and indexable (this[int] or this[Index]) types; slice patterns .. require Slice/range indexer support to capture (.. var rest). On arrays and strings, capturing a slice allocates a new array/string; on spans it's free.
whenorder switch
{
{ Total: > 1000m } when customer.IsVip => 0m,
...
}
when is evaluated only if the pattern matched; use it for conditions not expressible as patterns (method calls, cross-variable comparisons). Remember it defeats exhaustiveness analysis for that arm.
Pattern matching is generally as fast as hand-written if chains (often faster, due to shared tests). Type switches over many types are linear isinst checks; virtual dispatch can be faster for very large hierarchies, but for typical result types the difference is negligible.
(OrderStatus)99.using System.Diagnostics;
namespace Advanced.Patterns;
public enum CustomerType { Consumer, Business }
public sealed record Address(string State, string Country);
public sealed record Customer(CustomerType Type, Address Address, string? Abn);
public sealed record Order(Customer Customer, decimal Total, string Category, IReadOnlyList<string> Skus, bool IsCancelled);
public static class Gst
{
public static decimal For(Order order) => order switch
{
{ IsCancelled: true } => 0m,
{ Customer.Address.Country: not "AU" } => 0m, // export
{ Skus: [] } => 0m, // list pattern
{ Category: "BasicFood" or "Medical" } => 0m, // GST-free categories (simplified)
{ Customer: { Type: CustomerType.Business, Abn: null } } => throw new InvalidOperationException("Business customer without ABN"),
{ Total: > 0m and var total } => decimal.Round(total / 11m, 2), // GST-inclusive price
{ Total: <= 0m } => 0m,
};
public static string Describe(object? value) => value switch
{
null => "nothing",
int n when n < 0 => "negative int",
int and (0 or 1) => "bit",
int => "int",
string { Length: 0 } => "empty string",
string s => $"string of {s.Length}",
int[] and [var first, .., var last] => $"array {first}..{last}", // type pattern combined with a list pattern
_ => value.GetType().Name,
};
public static string Size(CustomerType type) => type switch
{
CustomerType.Consumer => "B2C",
CustomerType.Business => "B2B",
_ => throw new UnreachableException($"Unknown customer type {type}"), // enums can hold unnamed values
};
}
public static class Program
{
public static void Main()
{
var nsw = new Address("NSW", "AU");
var order = new Order(new Customer(CustomerType.Consumer, nsw, null), 110m, "Hardware", ["H1"], false);
Console.WriteLine(Gst.For(order)); // 10.00
Console.WriteLine(Gst.Describe(new[] { 3, 5, 9 })); // array 3..9
Console.WriteLine(Gst.Describe(1)); // bit
Console.WriteLine(Gst.Size(CustomerType.Business));
if (order is { Customer.Address: { State: "NSW" or "VIC" } address }) Console.WriteLine($"East coast: {address.State}");
}
}
What happens: switch expressions over enums or open hierarchies without _.
Why it is wrong: SwitchExpressionException at run time for unexpected values.
Do instead: add _ => throw new UnreachableException() (or a sensible default) and treat CS8509 as an error.
What happens: broad patterns before specific ones.
Why it is wrong: specific arms are unreachable (compile error) or wrongly shadowed with when guards.
Do instead: order from most specific to most general.
What happens: the same switch over subtypes in many services. Why it is wrong: adding a type means editing many places. Do instead: use polymorphism for open behaviour, or centralise the switch.
What happens: deeply nested property and list patterns in one arm. Why it is wrong: hard to read and debug. Do instead: extract helper properties or methods, or split into multiple arms.
when for everythingWhat happens: case Order o when o.Total > 1000 && o.Status == Paid.
Why it is wrong: loses exhaustiveness analysis and readability.
Do instead: use property and relational patterns.
not precedence errorsWhat happens: x is not null or "" expected to mean "not null and not empty".
Why it is wrong: not binds tighter than or.
Do instead: x is not (null or "").
What happens: list patterns capturing slices of arrays in hot paths. Why it is wrong: allocates new arrays. Do instead: match on spans or avoid capturing slices.
What happens: domain results get ToHttpResult() methods.
Why it is wrong: couples layers.
Do instead: pattern-match in the presentation layer.
Modern C# patterns let you write business rules as readable decision tables with compiler-checked exhaustiveness. Seniors know the full pattern set, order cases correctly, treat CS8509 as an error, and make a conscious choice between patterns (closed data, many operations) and polymorphism (open types, stable operations). I often combine them: polymorphic types for behaviour, pattern matching at the edges for parsing, mapping and rule tables.
An expression produces a value, like a + b or order.Total > 100; a statement does something, like if, foreach, return or a declaration. C# has become more expression-oriented: a switch expression returns a value, cannot fall through and warns if it is not exhaustive, and patterns (type, relational, property, list, and/or/not) let you test shape and values in one place. Expression-bodied members use => for one-line members, and throw expressions let ?? and ?: throw. I use expressions to compute values without mutation and statements when there are steps or side effects.
A switch expression returns a value: status switch { Paid => "Paid", _ => "Other" }. Each arm is a pattern, an optional when guard, and an expression. There's no fall-through or break, and the first matching arm wins. The result type comes from the arms or from the target. The compiler warns with CS8509 if it isn't exhaustive, and an unmatched value throws SwitchExpressionException. Switching on a tuple like (state, trigger) gives very readable state machines. When an arm needs several statements or side effects, a classic switch statement or polymorphism is clearer.
Deconstruction unpacks a value into separate variables, like var (id, name) = customer;. Tuples support it built in, positional records generate a Deconstruct method, and any other type works if it has a Deconstruct(out ..., out ...) method, which can be an extension, so you can add it to types you don't own. You can skip parts with _, deconstruct dictionary entries in foreach, and since C# 10 mix new and existing variables. Positional patterns like p is (0, var y) use the same Deconstruct method.
How to tell it (STAR)
**S:** Finance found GST errors on some overseas business invoices. **T:** Fix the calculation and make the rules auditable. **A:** I rewrote the nested conditions as one pattern-matching switch expression with table-driven tests, and the compiler's exhaustiveness warning exposed the missing case. **R:** The errors stopped, and finance can now review the rules as a readable table in [format].
Last updated 30 Oct 2026.