I’ve been writing C# for years. Most of what I know was passed down by seniors and inherited from old codebases. It worked — but it was written like it was 2003.

C# has had powerful pattern matching since version 8 — and most teams aren’t using it. Not because it’s hard, but because the old habits (if, is, cast) work fine and nobody told you there’s a better way.

This post walks through four patterns that will immediately clean up real code.


1. Type Patterns — Test and Bind in One Step

The old way: check the type, then cast separately.

// ❌ Before
string GetChannel(Notification n)
{
    if (n is EmailNotification)
        return "email:" + ((EmailNotification)n).To;
    if (n is SmsNotification)
        return "sms:" + ((SmsNotification)n).PhoneNumber;
    return "unknown";
}
// ✅ After
string GetChannel(Notification n) => n switch
{
    EmailNotification e   => $"email:{e.To}",
    SmsNotification sms   => $"sms:{sms.PhoneNumber}",
    PushNotification push => $"push:{push.DeviceToken}",
    InAppNotification     => "in-app",
    _                     => throw new ArgumentOutOfRangeException(nameof(n))
};

The bound variable (e, sms, push) is available right-hand side — no cast needed. Throwing on _ instead of a silent fallback is intentional: you want to know when a new subtype isn’t handled.


2. Property Patterns — Match on Shape, Not Just Type

Often you care about what’s inside the object too. Property patterns let you match on type and properties simultaneously — and they implicitly handle nulls.

// ❌ Before
string GetPriority(Notification n)
{
    if (n is PushNotification push)
    {
        if (push != null && push.IsUrgent) // redundant null check
            return "high";
        return "normal";
    }
    return "normal";
}
// ✅ After
string GetPriority(Notification n) => n switch
{
    PushNotification { IsUrgent: true } => "high",
    PushNotification                    => "normal",
    _                                   => "normal"
};

The key point: PushNotification { IsUrgent: true } only matches if n is a non-null PushNotification with IsUrgent == true. The null check is implicit. You can use is outside of switch too:

// null-safe check + bind in one line — no separate null guard needed
if (n is PushNotification { IsUrgent: true } push)
    SendUrgentAlert(push.DeviceToken);

Compare to the old way:

// ❌ Old: separate null check + cast
var push = n as PushNotification;
if (push != null && push.IsUrgent)
    SendUrgentAlert(push.DeviceToken);

3. Relational & Logical Patterns — Conditions Without Guard Clauses

// ❌ Before
string ClassifyLength(string message)
{
    if (message.Length <= 0)   return "empty";
    if (message.Length <= 80)  return "short";
    if (message.Length <= 160) return "standard";
    return "long";
}
// ✅ After
string ClassifyLength(string message) => message.Length switch
{
    <= 0   => "empty",
    <= 80  => "short",
    <= 160 => "standard",
    _      => "long"
};

and / or work as logical combinators in patterns:

bool ShouldRetry(int statusCode) => statusCode switch
{
    >= 500 and <= 599 => true,  // server errors
    408 or 429        => true,  // timeout / rate-limited
    _                 => false
};

4. List Patterns — Match on Collections

Added in C# 11, and genuinely underused. Instead of checking indices manually:

// ❌ Before
string DescribeArgs(string[] args)
{
    if (args.Length == 0) return "no args";
    if (args.Length == 1) return $"one arg: {args[0]}";
    if (args[0] == "--verbose") return $"verbose, {args.Length - 1} more";
    return $"{args.Length} args";
}
// ✅ After
string DescribeArgs(string[] args) => args switch
{
    []                         => "no args",
    [var only]                 => $"one arg: {only}",
    ["--verbose", .. var rest] => $"verbose, {rest.Length} more",
    var all                    => $"{all.Length} args"
};

.. is the slice pattern — it matches zero or more elements and can appear at any position. Without a binding (var rest), it’s a discard — you’re just saying “something here, I don’t care what.”

// First element must be "--verbose", rest is discarded
["--verbose", ..]

// Exactly one element followed by anything
[var first, ..]

// Anything followed by exactly one element
[.., var last]

// Exactly two elements — first bound, second discarded
[var head, _]

// First and last bound, middle discarded
[var first, .., var last]

Note that _ matches exactly one element (a wildcard), while .. matches any number including zero.


The Real Win: Exhaustiveness

Every switch expression that throws on _ will surface unhandled cases at runtime immediately, rather than silently routing to a wrong branch. With sealed subtypes, the compiler can verify exhaustiveness statically — no _ needed, and a missing arm is a compile error.

The next time you write a validation or branching condition, try it. You might not go back.


Further Reading