Why pattern matching keeps getting better with each C# version
Every major C# release since C# 7 has expanded pattern matching. Interviewers test it because it reflects modern idiomatic C# — candidates who know only C# 6 write verbose, multi-line type-checking code that modern C# collapses to a single expression. This guide covers every pattern type and when to reach for each.
The problem pattern matching solves
Pre-C# 7 type-dispatching code:
// Old style — verbose and error-prone:
object value = GetValue();
if (value is int)
{
int n = (int)value;
Console.WriteLine(n * 2);
}
else if (value is string)
{
string s = (string)value;
Console.WriteLine(s.ToUpper());
}
Modern C# 7+:
// Type pattern — test and bind in one step:
if (value is int n)
Console.WriteLine(n * 2);
else if (value is string s)
Console.WriteLine(s.ToUpper());
// Better: switch expression (C# 8):
string result = value switch
{
int n => $"int: {n * 2}",
string s => s.ToUpper(),
null => "null",
_ => value.GetType().Name
};
switch expression vs switch statement
The switch statement executes side effects. The switch expression produces
a value — it is an expression, not a statement.
// switch statement (C# 1+): imperative, branches, requires break
string label;
switch (status)
{
case OrderStatus.New: label = "New"; break;
case OrderStatus.Shipped: label = "Shipped"; break;
case OrderStatus.Delivered: label = "Delivered"; break;
default: label = "Unknown"; break;
}
// switch expression (C# 8+): expression, no break, exhaustiveness checked by compiler
string label2 = status switch
{
OrderStatus.New => "New",
OrderStatus.Shipped => "Shipped",
OrderStatus.Delivered => "Delivered",
_ => "Unknown",
};
The compiler warns if a switch expression might not cover all inputs (it is not exhaustive).
Omitting the discard _ arm when values are possible means a SwitchExpressionException
at runtime.
Type patterns
A type pattern tests the runtime type and binds the value:
Shape shape = GetShape();
// Type pattern in switch expression:
double area = shape switch
{
Circle c => Math.PI * c.Radius * c.Radius,
Rectangle r => r.Width * r.Height,
Triangle t => 0.5 * t.Base * t.Height,
_ => throw new ArgumentException($"Unknown shape: {shape.GetType().Name}")
};
// Inheritance: tests runtime type, not compile-time type
object obj = new Dog();
if (obj is Animal a) // matches — Dog IS-A Animal
Console.WriteLine(a.GetType().Name); // "Dog"
Type patterns never match null — if shape is null, none of the arms match and the
discard _ catches it.
Property patterns
Property patterns match objects by the value of their properties:
record Address(string Country, string PostCode);
record Customer(string Name, Address Address, bool IsPremium, decimal Balance);
Customer c = new("Alice", new Address("UK", "EC1A"), true, 500m);
string offer = c switch
{
// Nested property pattern (C# 10 dot notation):
{ IsPremium: true, Address.Country: "UK", Balance: > 1000 } => "Gold UK member",
{ IsPremium: true, Address.Country: "UK" } => "Premium UK",
{ IsPremium: true } => "Premium",
{ Address.Country: "UK" } => "Standard UK",
_ => "Standard"
};
Console.WriteLine(offer); // Premium UK
C# 10 introduced extended property patterns: { Address.Country: "UK" } instead of the
verbose C# 8 form { Address: { Country: "UK" } }.
Combine property patterns with type patterns:
object obj = new Customer("Bob", new Address("US", "NY"), false, 0m);
if (obj is Customer { Name: var name, Balance: 0, IsPremium: false })
Console.WriteLine($"Inactive customer: {name}");
Positional patterns
Positional patterns use a type's Deconstruct method. Records get deconstruction for free:
record Point(int X, int Y);
Point p = new(3, -5);
string quadrant = p switch
{
(0, 0) => "Origin",
(> 0, > 0) => "Q1",
(< 0, > 0) => "Q2",
(< 0, < 0) => "Q3",
(> 0, < 0) => "Q4",
_ => "On axis"
};
Console.WriteLine(quadrant); // Q4
// Tuples also support positional patterns:
(string role, bool isActive) user = ("admin", false);
string access = user switch
{
("admin", true) => "Full access",
("admin", false) => "Account suspended",
(_, true) => "Limited access",
_ => "No access"
};
Relational and logical patterns (C# 9)
Relational patterns compare with <, <=, >, >=. Logical patterns combine with
and, or, not:
int temperature = 72;
string weather = temperature switch
{
< 0 => "Freezing",
>= 0 and < 10 => "Cold",
>= 10 and < 20 => "Cool",
>= 20 and < 30 => "Comfortable",
>= 30 and < 40 => "Hot",
_ => "Extreme heat"
};
// Logical 'or':
DayOfWeek day = DateTime.Today.DayOfWeek;
bool isWeekend = day is DayOfWeek.Saturday or DayOfWeek.Sunday;
// Logical 'not':
string? name = GetName();
if (name is not null and { Length: > 0 })
Console.WriteLine(name.ToUpper());
Relational patterns only work with comparable value types (numeric types, char).
when guards
when adds an arbitrary boolean condition to a pattern arm. It runs only if the pattern
already matched:
// Use when for logic that can't be expressed as a pure pattern:
static string ClassifyOrder(Order order) => order switch
{
{ Status: OrderStatus.Shipped } when order.DaysInTransit > 14 => "Potentially lost",
{ Status: OrderStatus.Shipped } => "In transit",
{ Total: var t } when t > 10_000 => "High-value order",
{ IsPaid: false, Total: > 0 } => "Payment pending",
_ => "Standard"
};
The when filter runs after the pattern is tested, so the matched variable is
available in the guard expression.
List patterns (C# 11)
List patterns match arrays and lists by structure:
int[] data = { 1, 2, 3, 4, 5 };
// Exact match:
bool exact = data is [1, 2, 3, 4, 5]; // true
// Partial match with slice:
bool starts = data is [1, 2, ..]; // true — starts with 1, 2
bool ends = data is [.., 4, 5]; // true — ends with 4, 5
bool middle = data is [_, _, 3, ..]; // true — third element is 3
// Capture with var:
if (data is [var head, .. var rest])
Console.WriteLine($"head={head}, rest has {rest.Length} elements"); // head=1, rest has 4
// Routing example:
string[] segments = { "api", "products", "42" };
string response = segments switch
{
["api", "products", var id] => $"Get product {id}",
["api", "products"] => "List products",
["api", ..] => "Other API endpoint",
_ => "Not found"
};
Console.WriteLine(response); // Get product 42
Deconstruction and the var pattern
Deconstruction unpacks a type into components. The var pattern binds any value without
type-checking (always succeeds, including null):
// Deconstruction in assignment:
var (x, y) = new Point(3, 7);
var (name, age) = ("Alice", 30);
// var pattern — bind for use in when guard:
object obj = "hello world";
string desc = obj switch
{
null => "null",
var s when s is string str && str.Contains(' ') => $"sentence: {str}",
var s => $"other: {s}"
};
// Discard in deconstruction:
var (_, height) = new Rectangle(10, 5); // only care about height
// foreach deconstruction:
var people = new[] { ("Alice", 30), ("Bob", 25) };
foreach (var (n, a) in people)
Console.WriteLine($"{n} is {a}");
Null safety in patterns
Type patterns are null-safe by design — null never matches a type pattern:
object? value = null;
if (value is string s) // false — null is not a string; no NullReferenceException
Console.WriteLine(s);
// Prefer 'is null' and 'is not null' over == null:
string? name = GetName();
if (name is not null and { Length: > 0 }) // short-circuit: null check first
Console.WriteLine(name.ToUpper());
// Property pattern on nullable:
Address? addr = GetAddress();
if (addr is { Country: "UK", PostCode.Length: > 0 }) // safe — false if addr is null
Console.WriteLine("Valid UK address");
Recap
Pattern matching in C# has evolved from simple is-checks into a powerful expression-
based dispatch system. Switch expressions are value-producing, exhaustive-checked
alternatives to switch statements. Type patterns test and bind runtime types, safely
handling null. Property patterns dispatch on object shape, making business-rule code
declarative and readable. Positional patterns work with records and deconstruction.
Relational and logical patterns (and, or, not, <, >=) express range and
exclusion conditions without verbose when guards. List patterns (C# 11) match sequences
by structure. when guards add arbitrary conditions when pure patterns aren't enough.
The var pattern captures any value for use in a guard expression. In all cases, type
patterns never match null — use explicit null or not null patterns for null handling.