Pattern matching is one of the most powerful features added to C# in recent years. It allows you to test whether a value has a certain shape or characteristics and extract information from it in a single expression. This guide covers all the pattern matching features available in C# 12 and how to use them effectively.
Why Pattern Matching Matters
Before pattern matching, checking types and extracting values required verbose code with multiple if statements and explicit casts. Pattern matching lets you express the same logic more concisely and safely. The compiler verifies exhaustiveness, catches impossible patterns, and generates efficient code.
Type Patterns
The simplest form of pattern matching tests whether a value is of a specific type:
public static string DescribeObject(object obj)
{
return obj switch
{
string s => $"String with length {s.Length}",
int i => $"Integer: {i}",
double d => $"Double: {d:F2}",
bool b => b ? "True" : "False",
null => "Null value",
_ => $"Unknown type: {obj.GetType().Name}"
};
}
// Usage
Console.WriteLine(DescribeObject("hello")); // String with length 5
Console.WriteLine(DescribeObject(42)); // Integer: 42
Console.WriteLine(DescribeObject(3.14)); // Double: 3.14
Console.WriteLine(DescribeObject(null)); // Null value
You can also use type patterns in if statements with the is keyword:
public static void ProcessValue(object value)
{
if (value is string text)
{
Console.WriteLine($"Processing text: {text.ToUpper()}");
}
else if (value is int number and > 0)
{
Console.WriteLine($"Processing positive number: {number}");
}
else if (value is IEnumerable<int> numbers)
{
Console.WriteLine($"Processing {numbers.Count()} numbers");
}
}
Constant Patterns
Constant patterns match against specific values:
public static string GetDayType(DayOfWeek day)
{
return day switch
{
DayOfWeek.Saturday => "Weekend",
DayOfWeek.Sunday => "Weekend",
DayOfWeek.Monday => "Start of work week",
DayOfWeek.Friday => "Almost weekend",
_ => "Midweek"
};
}
public static string GetHttpStatusMessage(int statusCode)
{
return statusCode switch
{
200 => "OK",
201 => "Created",
204 => "No Content",
400 => "Bad Request",
401 => "Unauthorized",
403 => "Forbidden",
404 => "Not Found",
500 => "Internal Server Error",
502 => "Bad Gateway",
503 => "Service Unavailable",
_ => $"Unknown status code: {statusCode}"
};
}
Relational Patterns
Relational patterns let you compare values using comparison operators:
public static string GetTemperatureDescription(double celsius)
{
return celsius switch
{
< -20 => "Extremely cold",
< 0 => "Freezing",
< 10 => "Cold",
< 20 => "Cool",
< 30 => "Warm",
< 40 => "Hot",
_ => "Extremely hot"
};
}
public static string GetGrade(int score)
{
return score switch
{
>= 90 => "A",
>= 80 => "B",
>= 70 => "C",
>= 60 => "D",
_ => "F"
};
}
public static decimal CalculateDiscount(int quantity)
{
return quantity switch
{
<= 0 => throw new ArgumentException("Quantity must be positive"),
< 10 => 0m,
< 50 => 0.05m,
< 100 => 0.10m,
< 500 => 0.15m,
_ => 0.20m
};
}
Logical Patterns
Combine patterns using and, or, and not:
public static string ClassifyNumber(int number)
{
return number switch
{
< 0 => "Negative",
0 => "Zero",
> 0 and < 10 => "Single digit positive",
>= 10 and < 100 => "Two digit positive",
>= 100 and < 1000 => "Three digit positive",
_ => "Large positive number"
};
}
public static bool IsValidAge(int? age)
{
return age is not null and >= 0 and <= 150;
}
public static string DescribeChar(char c)
{
return c switch
{
>= 'a' and <= 'z' => "Lowercase letter",
>= 'A' and <= 'Z' => "Uppercase letter",
>= '0' and <= '9' => "Digit",
' ' => "Space",
_ => "Special character"
};
}
Property Patterns
Property patterns match against object properties:
public record Person(string Name, int Age, Address Address);
public record Address(string City, string Country, string PostalCode);
public static string DescribePerson(Person person)
{
return person switch
{
{ Age: < 18 } => "Minor",
{ Age: >= 65 } => "Senior",
{ Address.Country: "USA", Age: >= 21 } => "US adult who can drink",
{ Address.Country: "USA" } => "US adult",
{ Address.City: "London" } => "Londoner",
{ Name.Length: > 20 } => "Person with a long name",
_ => "Adult"
};
}
public static decimal CalculateShipping(Order order)
{
return order switch
{
{ Total: >= 100, ShippingAddress.Country: "USA" } => 0m,
{ Total: >= 50, ShippingAddress.Country: "USA" } => 5.99m,
{ ShippingAddress.Country: "USA" } => 9.99m,
{ Total: >= 200, ShippingAddress.Country: "Canada" or "Mexico" } => 0m,
{ ShippingAddress.Country: "Canada" or "Mexico" } => 14.99m,
_ => 29.99m
};
}
public record Order(decimal Total, Address ShippingAddress, List<OrderItem> Items);
public record OrderItem(string ProductName, int Quantity, decimal Price);
Positional Patterns
Positional patterns deconstruct objects using their Deconstruct method or tuple structure:
public static string DescribePoint((int X, int Y) point)
{
return point switch
{
(0, 0) => "Origin",
(0, _) => "On Y-axis",
(_, 0) => "On X-axis",
(var x, var y) when x == y => "On diagonal (y = x)",
(var x, var y) when x == -y => "On anti-diagonal (y = -x)",
(> 0, > 0) => "First quadrant",
(< 0, > 0) => "Second quadrant",
(< 0, < 0) => "Third quadrant",
(> 0, < 0) => "Fourth quadrant",
_ => "Unknown"
};
}
public class Rectangle
{
public int Width { get; }
public int Height { get; }
public Rectangle(int width, int height)
{
Width = width;
Height = height;
}
public void Deconstruct(out int width, out int height)
{
width = Width;
height = Height;
}
}
public static string DescribeRectangle(Rectangle rect)
{
return rect switch
{
(0, _) or (_, 0) => "Degenerate rectangle (line or point)",
(var w, var h) when w == h => $"Square with side {w}",
(var w, var h) when w > h => $"Landscape rectangle {w}x{h}",
(var w, var h) => $"Portrait rectangle {w}x{h}"
};
}
List Patterns (C# 11+)
List patterns match against arrays and lists:
public static string DescribeList(int[] numbers)
{
return numbers switch
{
[] => "Empty array",
[var single] => $"Single element: {single}",
[var first, var second] => $"Two elements: {first} and {second}",
[var first, .., var last] => $"Array starting with {first} and ending with {last}",
};
}
public static string AnalyzeSequence(int[] sequence)
{
return sequence switch
{
[] => "Empty sequence",
[0] => "Just zero",
[0, 1] => "Binary start",
[0, 1, 1, 2, 3, 5, ..] => "Looks like Fibonacci!",
[1, 2, 3, ..] => "Starts with 1, 2, 3",
[_, _, _, ..] => "At least three elements",
_ => "Some other sequence"
};
}
public static int SumFirstAndLast(int[] numbers)
{
return numbers switch
{
[] => 0,
[var only] => only,
[var first, .., var last] => first + last
};
}
// Slice patterns with capture
public static string AnalyzeArray(int[] array)
{
return array switch
{
[var first, .. var middle, var last] when middle.Length > 0 =>
$"First: {first}, Middle: [{string.Join(", ", middle)}], Last: {last}",
[var first, var last] => $"Two elements: {first}, {last}",
[var single] => $"Single: {single}",
[] => "Empty"
};
}
Recursive Patterns
Patterns can be nested to match complex object structures:
public abstract record Shape;
public record Circle(double Radius) : Shape;
public record Rectangle(double Width, double Height) : Shape;
public record Triangle(double Base, double Height) : Shape;
public record Group(Shape[] Shapes) : Shape;
public static double CalculateArea(Shape shape)
{
return shape switch
{
Circle { Radius: var r } => Math.PI * r * r,
Rectangle { Width: var w, Height: var h } => w * h,
Triangle { Base: var b, Height: var h } => 0.5 * b * h,
Group { Shapes: var shapes } => shapes.Sum(CalculateArea),
_ => throw new ArgumentException($"Unknown shape type: {shape.GetType().Name}")
};
}
public static string DescribeShape(Shape shape)
{
return shape switch
{
Circle { Radius: 0 } => "Point",
Circle { Radius: < 1 } => "Small circle",
Circle { Radius: >= 1 and < 10 } => "Medium circle",
Circle => "Large circle",
Rectangle { Width: var w, Height: var h } when w == h => $"Square with side {w}",
Rectangle { Width: var w, Height: var h } => $"Rectangle {w} x {h}",
Triangle { Base: var b, Height: var h } when b == h => "Isoceles triangle",
Triangle => "Triangle",
Group { Shapes: [] } => "Empty group",
Group { Shapes: [var single] } => $"Group with single shape: {DescribeShape(single)}",
Group { Shapes: var shapes } => $"Group with {shapes.Length} shapes",
_ => "Unknown shape"
};
}
Pattern Matching with When Guards
Add additional conditions to patterns using when clauses:
public static string ClassifyTriangle(double a, double b, double c)
{
return (a, b, c) switch
{
var (x, y, z) when x <= 0 || y <= 0 || z <= 0 => "Invalid: sides must be positive",
var (x, y, z) when x + y <= z || y + z <= x || x + z <= y => "Invalid: triangle inequality violated",
var (x, y, z) when x == y && y == z => "Equilateral",
var (x, y, z) when x == y || y == z || x == z => "Isosceles",
var (x, y, z) when x * x + y * y == z * z ||
y * y + z * z == x * x ||
x * x + z * z == y * y => "Right triangle",
_ => "Scalene"
};
}
public static decimal CalculateTax(decimal income, string state)
{
return (income, state) switch
{
(_, "TX" or "FL" or "NV") => 0m,
(< 10000, _) => 0m,
(var i, "CA") when i < 50000 => i * 0.06m,
(var i, "CA") when i < 100000 => i * 0.08m,
(var i, "CA") => i * 0.10m,
(var i, "NY") when i < 50000 => i * 0.05m,
(var i, "NY") => i * 0.085m,
(var i, _) => i * 0.05m
};
}
Real World Example: Expression Evaluator
Here is a complete example showing pattern matching for building an expression evaluator:
public abstract record Expression;
public record Number(double Value) : Expression;
public record Variable(string Name) : Expression;
public record BinaryOp(Expression Left, string Operator, Expression Right) : Expression;
public record UnaryOp(string Operator, Expression Operand) : Expression;
public record FunctionCall(string Name, Expression[] Arguments) : Expression;
public class ExpressionEvaluator
{
private readonly Dictionary<string, double> _variables;
public ExpressionEvaluator(Dictionary<string, double> variables = null)
{
_variables = variables ?? new Dictionary<string, double>();
}
public double Evaluate(Expression expr)
{
return expr switch
{
Number { Value: var v } => v,
Variable { Name: var name } when _variables.TryGetValue(name, out var value) => value,
Variable { Name: var name } => throw new ArgumentException($"Unknown variable: {name}"),
BinaryOp { Left: var left, Operator: "+", Right: var right } =>
Evaluate(left) + Evaluate(right),
BinaryOp { Left: var left, Operator: "-", Right: var right } =>
Evaluate(left) - Evaluate(right),
BinaryOp { Left: var left, Operator: "*", Right: var right } =>
Evaluate(left) * Evaluate(right),
BinaryOp { Left: var left, Operator: "/", Right: var right } =>
Evaluate(left) / Evaluate(right),
BinaryOp { Left: var left, Operator: "^", Right: var right } =>
Math.Pow(Evaluate(left), Evaluate(right)),
BinaryOp { Operator: var op } =>
throw new ArgumentException($"Unknown operator: {op}"),
UnaryOp { Operator: "-", Operand: var operand } => -Evaluate(operand),
UnaryOp { Operator: "+", Operand: var operand } => Evaluate(operand),
UnaryOp { Operator: var op } =>
throw new ArgumentException($"Unknown unary operator: {op}"),
FunctionCall { Name: "sin", Arguments: [var arg] } => Math.Sin(Evaluate(arg)),
FunctionCall { Name: "cos", Arguments: [var arg] } => Math.Cos(Evaluate(arg)),
FunctionCall { Name: "tan", Arguments: [var arg] } => Math.Tan(Evaluate(arg)),
FunctionCall { Name: "sqrt", Arguments: [var arg] } => Math.Sqrt(Evaluate(arg)),
FunctionCall { Name: "abs", Arguments: [var arg] } => Math.Abs(Evaluate(arg)),
FunctionCall { Name: "log", Arguments: [var arg] } => Math.Log(Evaluate(arg)),
FunctionCall { Name: "log", Arguments: [var arg, var baseArg] } =>
Math.Log(Evaluate(arg), Evaluate(baseArg)),
FunctionCall { Name: "max", Arguments: var args } when args.Length >= 2 =>
args.Select(Evaluate).Max(),
FunctionCall { Name: "min", Arguments: var args } when args.Length >= 2 =>
args.Select(Evaluate).Min(),
FunctionCall { Name: var name } =>
throw new ArgumentException($"Unknown function: {name}"),
_ => throw new ArgumentException($"Unknown expression type: {expr.GetType().Name}")
};
}
}
// Usage
var evaluator = new ExpressionEvaluator(new Dictionary<string, double>
{
["x"] = 5,
["y"] = 3
});
var expr = new BinaryOp(
new FunctionCall("sqrt", new[] { new Variable("x") }),
"+",
new BinaryOp(new Variable("y"), "^", new Number(2))
);
var result = evaluator.Evaluate(expr); // sqrt(5) + 3^2 = 2.236 + 9 = 11.236
Best Practices
When using pattern matching, follow these guidelines:
Order patterns from most specific to least specific. The compiler evaluates them top to bottom.
Use the discard pattern (_) as a catch-all at the end to ensure exhaustiveness.
Prefer switch expressions over switch statements when returning a value.
Use property patterns to avoid casting and null checks.
Combine patterns with when guards for complex conditions.
Keep patterns readable. If a pattern becomes too complex, extract it into a separate method.
Conclusion
Pattern matching in C# has evolved significantly and now provides a rich vocabulary for expressing conditions and extracting data. From simple type checks to complex recursive patterns, these features help you write code that is both more concise and more expressive.
The key benefits are:
- Compile-time exhaustiveness checking catches missing cases
- Reduced boilerplate compared to if-else chains
- Clearer expression of intent
- Better performance through compiler optimizations
Start using pattern matching in your codebase and you will find many places where it simplifies complex conditional logic.