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.