Pattern Matching in Modern C#: Write Less, Express More

How switch expressions, property patterns and list patterns turn tangled if/else chains into code that reads like a spec.

Charan Pasham··3 min read

21 Views

Why pattern matching matters

Most business logic boils down to one question: what shape is this data, and what should I do about it? For years, C# developers answered that with long if/else chains and type casts. Since C# 7, and much more so in C# 9–11, the language has grown a rich pattern matching system that answers it declaratively.

Here's the kind of code we want to get away from:

static double Area(object shape)
{
    if (shape is Circle)
    {
        var c = (Circle)shape;
        return Math.PI * c.Radius * c.Radius;
    }
    else if (shape is Rectangle)
    {
        var r = (Rectangle)shape;
        return r.Width * r.Height;
    }
    throw new ArgumentException("Unknown shape");
}

It works, but it's noisy: every branch repeats the type check, the cast and the variable.

Switch expressions and type patterns

Combine record types with a switch expression and the same logic collapses into a single expression:

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;

static double Area(Shape shape) => shape switch
{
    Circle c                                  => Math.PI * c.Radius * c.Radius,
    Rectangle { Width: var w, Height: var h } => w * h,
    Triangle t                                => 0.5 * t.Base * t.Height,
    _ => throw new ArgumentOutOfRangeException(nameof(shape))
};

Each arm is a pattern followed by => and a result. The compiler checks the arms in order and warns you if you've missed a case.

Good code reads like a description of the problem, not a list of instructions for the computer.

Relational and logical patterns

C# 9 added relational operators (<, >= and friends) and the logical keywords and, or and not. Range checks become very readable:

static string Classify(int celsius) => celsius switch
{
    < 0            => "Freezing",
    >= 0 and < 15  => "Cold",
    >= 15 and < 25 => "Pleasant",
    _              => "Hot"
};

No more if (x >= 15 && x < 25). The intent is right there on the page.

Property patterns: matching on what's inside

Property patterns let you match on an object's data, including nested properties (extended property patterns arrived in C# 10). Imagine a shipping calculator:

public record Customer(string Country, bool IsPremium);
public record Order(decimal Total, Customer Customer, bool IsExpress);

static decimal ShippingCost(Order order) => order switch
{
    { Customer.IsPremium: true }                 => 0m,
    { IsExpress: true, Customer.Country: "US" }  => 15m,
    { Total: >= 100m }                           => 0m,
    { Customer.Country: not "US" }               => 25m,
    _                                            => 5m
};

Read it out loud and it's the business rule word for word: premium customers ship free; express in the US costs $15; orders over $100 ship free; international costs $25; everything else is $5.

List patterns

C# 11 introduced list patterns, which match arrays and lists by their elements. The slice pattern .. stands for "zero or more elements":

static string Describe(int[] values) => values switch
{
    []                        => "Empty",
    [var only]                => $"Just one item: {only}",
    [var first, .., var last] => $"Starts with {first}, ends with {last}"
};

This is especially handy for parsing command-line arguments or simple tokens.

Tuple patterns

Need to decide based on two values at once? Switch on a tuple:

static string Winner(string p1, string p2) => (p1, p2) switch
{
    var (a, b) when a == b => "Tie",
    ("rock", "scissors") or ("scissors", "paper") or ("paper", "rock") => "Player 1",
    _ => "Player 2"
};

Tips for using patterns well

  1. Order matters. Arms are checked top to bottom, so put the most specific patterns first.

  2. Let the compiler help. Avoid a catch-all _ when you can; without it, the compiler will warn you about unhandled cases.

  3. Pair patterns with records. Immutable records and positional deconstruction are a natural fit.

  4. Don't over-nest. If a single pattern takes more than a line or two to read, pull it out into a well-named method.

A few quick rules of thumb:

  • Use is for a single check: if (obj is string { Length: > 0 } s)

  • Use a switch expression when you're producing a value

  • Use a switch statement when each branch performs side effects

Pattern Matching in Modern C#: Write Less, Express More · Byte Blog