C# pattern matching isn’t just about switch statements anymore; it’s evolved into a powerful expression that can dramatically simplify complex conditional logic.

Let’s see it in action. Imagine you have a Shape hierarchy and you want to calculate its area.

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 static class ShapeCalculator
{
    public static double CalculateArea(Shape shape) =>
        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 type")
        };

    public static void Main(string[] args)
    {
        var circle = new Circle(5);
        var rectangle = new Rectangle(4, 6);
        var triangle = new Triangle(3, 8);

        Console.WriteLine($"Circle area: {CalculateArea(circle)}");
        Console.WriteLine($"Rectangle area: {CalculateArea(rectangle)}");
        Console.WriteLine($"Triangle area: {CalculateArea(triangle)}");
    }
}

This switch expression is a game-changer. Instead of a statement that executes code, it’s an expression that evaluates to a value. Notice how Circle c => ... declares a new variable c of type Circle and binds it to the matched shape instance, allowing direct access to its properties. This is called a property pattern.

Beyond property patterns, C# 9 introduced positional patterns for deconstructing types like records. Consider a Point record:

public record Point(int X, int Y);

public static class PointProcessor
{
    public static string GetQuadrant(Point p) =>
        p switch
        {
            (0, 0) => "Origin",
            (var x, 0) when x > 0 => "Positive X-axis",
            (var x, 0) when x < 0 => "Negative X-axis",
            (0, var y) when y > 0 => "Positive Y-axis",
            (0, var y) when y < 0 => "Negative Y-axis",
            (var x, var y) when x > 0 && y > 0 => "Quadrant 1",
            (var x, var y) when x < 0 && y > 0 => "Quadrant 2",
            (var x, var y) when x < 0 && y < 0 => "Quadrant 3",
            (var x, var y) when x > 0 && y < 0 => "Quadrant 4",
            _ => "Unknown" // Should not happen for valid points
        };

    public static void Main(string[] args)
    {
        Console.WriteLine($"Point (3, 4) is in: {GetQuadrant(new Point(3, 4))}");
        Console.WriteLine($"Point (-2, 5) is in: {GetQuadrant(new Point(-2, 5))}");
        Console.WriteLine($"Point (0, 0) is in: {GetQuadrant(new Point(0, 0))}");
        Console.WriteLine($"Point (5, 0) is in: {GetQuadrant(new Point(5, 0))}");
    }
}

Here, (var x, var y) is a positional pattern. It deconstructs the Point record into its constituent properties, X and Y, and binds them to variables x and y respectively. This is incredibly concise compared to manually accessing p.X and p.Y. Notice also the use of when clauses – these are guards, allowing you to add additional boolean conditions to a pattern. The pattern only matches if both the structure matches and the guard condition is true.

The power of pattern matching extends to more complex scenarios. You can combine patterns, match against constant values, use var to declare new variables within a pattern, and even match against null.

The true elegance of this system emerges when you realize you can nest patterns. Consider a scenario where you have a Message object that could be a TextMessage or a CommandMessage, and CommandMessage has a Payload which itself could be a string or a byte[].

public abstract record Message;
public record TextMessage(string Content) : Message;
public record CommandMessage(object Payload) : Message;

public static class MessageParser
{
    public static string ParseMessage(Message msg) =>
        msg switch
        {
            TextMessage tm => $"Text: {tm.Content.ToUpper()}",
            CommandMessage cm => cm.Payload switch
            {
                string s => $"Command string payload: {s.Length} chars",
                byte[] b => $"Command byte payload: {b.Length} bytes",
                _ => "Unknown command payload type"
            },
            _ => "Unknown message type"
        };

    public static void Main(string[] args)
    {
        var text = new TextMessage("hello world");
        var cmdString = new CommandMessage("execute");
        var cmdBytes = new CommandMessage(new byte[] { 0x01, 0x02, 0x03 });
        var cmdUnknown = new CommandMessage(123);

        Console.WriteLine(ParseMessage(text));
        Console.WriteLine(ParseMessage(cmdString));
        Console.WriteLine(ParseMessage(cmdBytes));
        Console.WriteLine(ParseMessage(cmdUnknown));
    }
}

This nested switch expression, where the pattern for CommandMessage itself contains another switch expression, demonstrates how you can deeply inspect and deconstruct complex data structures with remarkable clarity. The inner switch on cm.Payload uses type patterns (string s, byte[] b) to match the payload’s type and bind it to a variable.

What most developers don’t immediately grasp is that the _ discard pattern isn’t just for the end of a switch. It can be used anywhere a pattern is expected, acting as a wildcard that matches anything without binding it to a variable. This is incredibly useful when you only care about part of a deconstructed type or when you want to explicitly ignore a specific case. For instance, if you only cared about Circles and wanted to treat all other shapes the same, you could write Circle c => ..., _ => HandleOtherShapes().

The next logical step in exploring C#’s pattern matching capabilities is diving into not patterns and and/or patterns for even more sophisticated conditional logic.

Want structured learning?

Take the full Csharp course →