Mert Özen Further With Every Line
Backend Advanced C# — #04

Pattern Matching in C# in Depth: Property, Positional and Tuple Patterns

Mert Özen Nov 7, 2026 11 min 6 views
Pattern Matching in C# in Depth: Property, Positional and Tuple Patterns

Property patterns and the C# 10 extended syntax, a match that quietly fails at a null intermediate step, positional patterns with Deconstruct, a decision table with tuple patterns, not/and/or precedence and the is not A or B trap, the var pattern and when, missing and unreachable arms with CS8509, CS8524 and CS8510, and choosing between pattern matching and polymorphism.

Version note: The code in this post uses C# 10 and the .NET 6 APIs; it was built and run with the .NET 10 SDK.

Guess First

We want to flag books that are out of stock, or expensive books that aren't preorders. The book we have has stock 0, costs 45.90 and is a preorder. Does it print flagged or ok?

Book book = new Book("Dune", Stock: 0, Price: 45.90m, IsPreorder: true);

if (book is { Stock: 0 } or { Price: > 100m } and { IsPreorder: false })
{
    Console.WriteLine("flagged");
}
else
{
    Console.WriteLine("ok");
}

public record Book(string Title, int Stock, decimal Price, bool IsPreorder);

Reading left to right you'd say "stock is 0 or the price is over 100, and it's not a preorder"; since the book is a preorder, you'd expect ok. The compiler reads it differently. The answer is in the section on the precedence of logical patterns.

Property Patterns and the C# 10 Extended Form

In the Fundamentals series we met type, relational and logical patterns. The one that will help you most in real code is the property pattern: deciding by looking at an object's properties. Let's produce a shipping description for Bookstore orders:

using System.Globalization;

CultureInfo.CurrentCulture = CultureInfo.InvariantCulture;

Order[] orders =
{
    new Order(1, new Customer("Ada", false, new Address("Ankara", "TR")), 620m),
    new Order(2, new Customer("Alan", false, new Address("Izmir", "TR")), 45.90m),
    new Order(3, new Customer("Grace", true, new Address("Berlin", "DE")), 80m),
    new Order(4, new Customer("Linus", false, null), 30m),
    new Order(5, new Customer("Edsger", false, new Address("Delft", "NL")), 75m)
};

foreach (Order order in orders)
{
    Console.WriteLine($"{order.Id}: {Describe(order)}");
}

if (orders[0].Customer.Address is { } address)
{
    Console.WriteLine($"first order ships to {address.City}");
}

static string Describe(Order order) => order switch
{
    // C# 10: nested properties with a dot
    { Customer.Address.Country: "TR", Total: >= 500m } => "domestic, free shipping",
    { Customer.Address.Country: "TR" } => "domestic",
    { Customer.Address: null } => "missing address",
    // C# 8-9 syntax: nested braces
    { Customer: { IsPremium: true } } => "international, premium",
    _ => "international"
};

public record Address(string City, string Country);
public record Customer(string Name, bool IsPremium, Address? Address);
public record Order(int Id, Customer Customer, decimal Total);
1: domestic, free shipping
2: domestic
3: international, premium
4: missing address
5: international
first order ships to Ankara

A property pattern is written as { Property: pattern }, and every comma-separated item must hold at the same time. Before C# 10, reaching a nested property meant nesting braces; the fourth arm uses that old syntax. With C# 10's extended property pattern you can write Customer.Address.Country; both produce the same code, but the dotted one is much easier to read.

Look at order 4. Its address is null and the first two arms look at Customer.Address.Country, yet there's no NullReferenceException. A property pattern checks that a value isn't null before looking inside it; at a null intermediate step the match quietly fails and the next arm is tried. For the same reason is { } means "not null"; the is { } address at the end both checks and binds the value, now known to be non-null, to a new variable; the last line of the output comes from there.

Positional Patterns and Deconstruct

If a type can break itself into parts, that is, if it has a Deconstruct method, you can match by position instead of property names. Positional records get this method automatically; on an ordinary class you write it yourself:

using System.Globalization;

CultureInfo.CurrentCulture = CultureInfo.InvariantCulture;

Book[] books =
{
    new Book("Dune", "Frank Herbert", 45.90m),
    new Book("The Art of Computer Programming", "Donald Knuth", 249.00m),
    new Book("Clean Code", "Robert C. Martin", 62.00m)
};

foreach (Book book in books)
{
    Console.WriteLine($"{book.Title}: {Label(book)}");
}

Money refund = new Money(-12.50m, "EUR");
Console.WriteLine(refund is (< 0m, "EUR") ? "EUR refund" : "other");

static string Label(Book book) => book switch
{
    (_, "Frank Herbert", _) => "sci-fi classic",
    (_, _, > 100m) => "premium",
    _ => "regular"
};

public record Book(string Title, string Author, decimal Price);

public class Money
{
    public decimal Amount { get; }
    public string Currency { get; }

    public Money(decimal amount, string currency)
    {
        Amount = amount;
        Currency = currency;
    }

    // the positional pattern uses this method
    public void Deconstruct(out decimal amount, out string currency)
    {
        amount = Amount;
        currency = Currency;
    }
}
Dune: sci-fi classic
The Art of Computer Programming: premium
Clean Code: regular
EUR refund

(_, "Frank Herbert", _) means "the second part is Frank Herbert, the rest doesn't matter". Here _ is the discard pattern: it matches anything. Positional patterns look good on types with two or three parts whose meaning is clear from their order: an amount and its currency, a coordinate. If you find yourself writing (_, _, _, > 0, _) for a five-field record, nobody will remember which part is which; at that point go back to property patterns.

Tuple Patterns: Looking at Several Values at Once

Some rules depend not on a single value but on a combination of values. Let the shipping fee vary by membership type, cart total and express delivery. Instead of nested ifs, you can gather the three values into a tuple and decide with a single switch:

using System.Globalization;

CultureInfo.CurrentCulture = CultureInfo.InvariantCulture;

Console.WriteLine(ShippingFee(Membership.Gold, 20m, isExpress: true));
Console.WriteLine(ShippingFee(Membership.None, 150m, isExpress: false));
Console.WriteLine(ShippingFee(Membership.None, 150m, isExpress: true));
Console.WriteLine(ShippingFee(Membership.Silver, 40m, isExpress: true));
Console.WriteLine(ShippingFee(Membership.None, 40m, isExpress: false));

static decimal ShippingFee(Membership membership, decimal total, bool isExpress) =>
    (membership, total, isExpress) switch
    {
        (Membership.Gold, _, _) => 0m,
        (_, >= 100m, false) => 0m,
        (Membership.Silver, _, true) => 5.00m,
        (_, _, true) => 12.50m,
        _ => 4.90m
    };

public enum Membership
{
    None,
    Silver,
    Gold
}
0
0
12.50
5.00
4.90

The switch reads like a decision table: each row a rule, each column an input. Arms are tried from top to bottom and the first match wins; so the most specific rules go at the top and the most general at the bottom. A Gold member gets free shipping in every case, because the first arm catches them. The 150 order that asked for express delivery didn't fit the second arm (isExpress isn't false) and fell through to the fourth. No need to shy away from the tuple: ValueTuple is a struct and allocates nothing on the heap.

Relational and Logical Patterns, Precedence

The answer to the guess: flagged. In logical patterns the precedence is not, then and, and last or; just like !, && and ||. The compiler read the guess example as { Stock: 0 } or ({ Price: > 100m } and { IsPreorder: false }), and since stock was 0 the first part was enough. If what you meant is "either of the two, and not a preorder", you must add the parentheses yourself. A few common uses and one more trap:

using System.Globalization;

CultureInfo.CurrentCulture = CultureInfo.InvariantCulture;

foreach (decimal price in new[] { 0m, 12.99m, 45.90m, 249m })
{
    Console.WriteLine($"{price}: {Bucket(price)}");
}

Console.WriteLine(IsAsciiLetter('q'));
Console.WriteLine(IsAsciiLetter('7'));

OrderStatus status = OrderStatus.Delivered;
Console.WriteLine(status is not OrderStatus.Shipped or OrderStatus.Delivered);
Console.WriteLine(status is not (OrderStatus.Shipped or OrderStatus.Delivered));

static string Bucket(decimal price) => price switch
{
    < 0m => throw new ArgumentOutOfRangeException(nameof(price)),
    0m => "free",
    > 0m and < 20m => "budget",
    >= 20m and < 100m => "standard",
    _ => "premium"
};

static bool IsAsciiLetter(char c) => c is (>= 'a' and <= 'z') or (>= 'A' and <= 'Z');

public enum OrderStatus
{
    Pending,
    Shipped,
    Delivered
}
0: free
12.99: budget
45.90: standard
249: premium
True
False
True
False

>= 20m and < 100m defines a range, and the arms in Bucket cover the ranges without gaps. An arm of a switch expression can also throw instead of returning a value; rejecting invalid input in the first arm is a readable habit.

The trap is in the last two lines. Meaning "if it's neither Shipped nor Delivered" but writing is not OrderStatus.Shipped or OrderStatus.Delivered means (not Shipped) or Delivered, because not binds first. For a Delivered order the result was True; exactly the case you wanted to leave out. The compiler accepted this line silently. If you want to apply not to a group, parentheses are a must.

The var Pattern and when

Some rules look at things patterns can't express: comparing two properties with each other, or doing a calculation. Patterns can only compare against constants; for this you bind the value to a variable with the var pattern and write any condition you like with when:

using System.Globalization;

CultureInfo.CurrentCulture = CultureInfo.InvariantCulture;

Book dune = new Book("Dune", 45.90m, 3);
Book knuth = new Book("TAOCP Box Set", 249.00m, 20);

Console.WriteLine(Check(new OrderItem(dune, 0)));
Console.WriteLine(Check(new OrderItem(dune, 5)));
Console.WriteLine(Check(new OrderItem(knuth, 5)));
Console.WriteLine(Check(new OrderItem(dune, 2)));

static string Check(OrderItem item) => item switch
{
    { Quantity: <= 0 } => "invalid quantity",
    { Quantity: var quantity, Book.Stock: var stock } when quantity > stock => $"only {stock} left",
    { Book.Price: var price, Quantity: var quantity } when price * quantity > 1000m => "needs approval",
    _ => "ok"
};

public record Book(string Title, decimal Price, int Stock);
public record OrderItem(Book Book, int Quantity);
invalid quantity
only 3 left
needs approval
ok

var quantity matches anything, even null; its job is to capture the value. Then when puts an extra condition on that arm, and if the condition is false the arm counts as not matched and the next one is tried. You'll see in the pitfalls section that you can't write a comparison against a variable inside a pattern; when fills exactly that gap. Still, if you start adding when to every arm, you're really writing an if chain.

Exhaustiveness: Missing and Unreachable Arms

A switch expression must always return a value. What happens if no arm matches? The compiler tries to see it coming:

Console.WriteLine(Describe(OrderStatus.Paid));
Console.WriteLine(Describe(OrderStatus.Cancelled));

static string Describe(OrderStatus status) => status switch
{
    OrderStatus.Pending => "waiting for payment",
    OrderStatus.Paid => "preparing",
    OrderStatus.Shipped => "on the way",
    OrderStatus.Delivered => "delivered"
};

public enum OrderStatus
{
    Pending,
    Paid,
    Shipped,
    Delivered,
    Cancelled
}
Program.cs(4,54): warning CS8509: The switch expression does not handle all possible values of its input type (it is not exhaustive). For example, the pattern 'OrderStatus.Cancelled' is not covered.
preparing
Unhandled exception. System.Runtime.CompilerServices.SwitchExpressionException: Non-exhaustive switch expression failed to match its input.
Unmatched value was Cancelled.

The warning names the missing value. Take it seriously; it's what shows you every switch that doesn't handle a new value when one is added to the enum. After adding a Cancelled arm, the warning changed: warning CS8524: The switch expression does not handle some values of its input type (it is not exhaustive) involving an unnamed enum value. For example, the pattern '(OrderStatus)5' is not covered. Enums are really numbers and (OrderStatus)42 is a valid value; when I tried it, the same exception came with Unmatched value was 42. If you work with data from outside, put a _ => throw new ArgumentOutOfRangeException(nameof(status)) arm at the end. For an enum fully under your control, consciously accepting CS8524 is the price of keeping CS8509's protection: the moment you add a _ arm, you stop getting warnings for new enum values too.

The compiler's second check works the other way: if you wrote an arm that can never be reached, it reports an error.

Console.WriteLine(StockLabel(25));

static string StockLabel(int stock) => stock switch
{
    > 0 => "in stock",
    > 10 => "plenty",
    _ => "out of stock"
};
Program.cs(6,5): error CS8510: The pattern is unreachable. It has already been handled by a previous arm of the switch expression or it is impossible to match.

Every number above 10 is already above 0; the second arm can never run. Reversing the order, putting the specific one on top, fixes the error. if/else chains have no such check; that's another advantage of the switch.

Pattern Matching or Polymorphism

In the OOP series we learned to put behavior inside the types, in virtual methods. Pattern matching makes the decision outside, in one place. Which one when? If new types are likely and each type should know its own behavior, polymorphism; each new type brings its own override without changing any switch. If the types are fixed but the operations on them keep growing, pattern matching; each new operation, such as shipping fee, tax or a report label, is added as a single switch without touching the types. Bookstore's orders, payment states and shipping rules fall into the second group. Then there are rules that depend not on one object but on several; as in the tuple pattern example, no single type can own such a decision.

C# 11 brought list patterns ([var first, .., var last]); we'll see them in the C# 11 post of the C# Versions series.

Common Pitfalls

Trying to use a variable in a pattern. Values in patterns must be constants known at compile time:

decimal limit = 100m;
decimal price = 120m;
bool expensive = price is > limit;
Program.cs(3,29): error CS9135: A constant value of type 'decimal' is expected

Either make limit a const, or use when and an ordinary comparison.

Forgetting the order of arms. The first matching arm wins. If you put a general arm above a specific one, the compiler sometimes catches it with CS8510, but not for arms with when, because it can't know the result of the when in advance.

Combining not and or without parentheses. As you saw above, is not A or B almost always expresses the wrong intent.

Silencing CS8509 with _ => null. Adding a meaningless default at the end to make the warning go away also turns off every future warning for values added to the enum. If there really is no default, throw an exception.

A Small Experiment

★☆☆ Fix the condition in the guess example with parentheses so it means "books that are out of stock or cost over 100, and aren't preorders", and get ok as output.

★★☆ Add the country to ShippingFee: (Membership, string Country, decimal Total, bool IsExpress). For orders outside Turkey, let Gold members pay 9.90 too. Verify with the output which arm needs to go where.

★★★ Write a CanTransition(OrderStatus from, OrderStatus to) that takes two states: Pending can go to Paid or Cancelled, Paid to Shipped or Cancelled, Shipped to Delivered. Use a tuple pattern and or, then add Returned to the enum. Did the compiler tell you anything? If not, why not?

In this post the address of order 4 was null, and pattern matching handled it gracefully. But there's a more systematic way to tell the compiler where null can come from. In the next post we open up nullable reference types in depth: attributes such as NotNullWhen and MaybeNull, and designing a null-safe library API.