Pattern Matching & Switch Expressions
Reviewed & published by Brayan K
Modern C# lets you ask "what shape is this value?" and branch on the answer in a single, elegant expression. Pattern matching replaces towers of nested if/else with code that reads like a decision table. In this lesson you'll learn the is operator, switch expressions, and the type, relational, property and when patterns that make them powerful.
Part of the free C# course at LearnCodingFast — hands-on lessons with worked examples and the output they print, plus practice exercises and a quick quiz.
What You'll Learn
- The 'is' operator and type patterns
- Constant, null, and relational patterns
- Switch expressions that return a value
- Property patterns to match object fields
- 'when' guards for extra conditions
- The discard pattern _ and exhaustiveness
💡 Real-World Analogy
Think of an airport security line with several lanes: "passengers with no bags here", "first class there", "anyone with liquids over 100ml step aside". Each lane is a pattern — a description of who belongs in it — and each traveller is routed to the first lane they match. A switch expression is exactly that: a list of patterns, each with an outcome, and your value walks down the lanes until one fits.
1. The is Operator & Type Patterns
The is keyword tests whether a value matches a pattern, and can capture it into a new variable at the same time. value is string s means "if value is a string, give me it as s". You'll also use is null and is not null constantly — they read like plain English.
using System;
class Program
{
static void Main()
{
// Pattern matching tests "does this value match a shape?"
// The 'is' keyword is the simplest form.
object value = "hello";
// TYPE pattern: is the object a string? If so, capture it as 's'.
if (value is string s)
Console.WriteLine($"It's a string of length {s.Length}"); // length 5
object number = 42;
// CONSTANT pattern: compare directly to a literal value.
if (number is 42)
Console.WriteLine("The answer!"); // prints
// 'is null' and 'is not null' read like English:
string? maybe = null;
if (maybe is null)
Console.WriteLine("Nothing here"); // prints
object name = "Sam";
if (name is not null)
Console.WriteLine("We have a value"); // prints
}
}
// ✅ Expected output:
// It's a string of length 5
// The answer!
// Nothing here
// We have a value2. Switch Expressions
A switch expression turns a value into another value. Note the differences from the old switch statement: the value comes before the switch keyword, arms use => arrows and commas, there's no case or break, and _ is the catch-all. Arms are tested top to bottom and the first match wins — so order your relational patterns from most to least specific.
using System;
class Program
{
// A switch EXPRESSION returns a value — note the => arrows and no 'case'.
static string Describe(int score) => score switch
{
100 => "Perfect!", // constant pattern
>= 90 => "Excellent", // relational pattern
>= 70 => "Good",
>= 50 => "Pass",
_ => "Try again" // _ is the default ("anything else")
};
static void Main()
{
Console.WriteLine(Describe(100)); // Perfect!
Console.WriteLine(Describe(95)); // Excellent
Console.WriteLine(Describe(72)); // Good
Console.WriteLine(Describe(40)); // Try again
// The first matching arm wins, top to bottom — order matters!
}
}
// ✅ Expected output:
// Perfect!
// Excellent
// Good
// Try againYour turn — complete the ticket-pricing switch by filling in the relational patterns.
using System;
class Program
{
// 🎯 YOUR TURN — fill in the switch arms (replace each ___).
static string Ticket(int age) => age switch
{
___ => "Child", // 👉 < 12 => "Child"
___ => "Teen", // 👉 < 18 => "Teen"
___ => "Senior", // 👉 >= 65 => "Senior"
_ => "Adult"
};
static void Main()
{
Console.WriteLine(Ticket(8)); // expected: Child
Console.WriteLine(Ticket(15)); // expected: Teen
Console.WriteLine(Ticket(70)); // expected: Senior
Console.WriteLine(Ticket(30)); // expected: Adult
}
}3. when Guards
Sometimes a pattern isn't quite enough — you also need a condition. A when guard adds any boolean expression to an arm: it only matches if both the pattern and the guard are true. This keeps complex rules readable instead of nesting ifs.
using System;
class Program
{
// 'when' adds an extra boolean test (a "guard") to any pattern.
static string Classify(int n) => n switch
{
0 => "zero",
> 0 when n % 2 == 0 => "positive even",
> 0 => "positive odd",
< 0 when n % 2 == 0 => "negative even",
_ => "negative odd"
};
static void Main()
{
Console.WriteLine(Classify(0)); // zero
Console.WriteLine(Classify(4)); // positive even
Console.WriteLine(Classify(7)); // positive odd
Console.WriteLine(Classify(-6)); // negative even
Console.WriteLine(Classify(-3)); // negative odd
}
}
// ✅ Expected output:
// zero
// positive even
// positive odd
// negative even
// negative odd4. Property Patterns
Property patterns peek inside an object and match against its properties using { } braces: { Status: "paid", Total: > 50 }. You can match several properties at once, and combine them with type patterns. This is where pattern matching really shines — describing the exact shape of data you care about.
using System;
class Program
{
record Order(string Status, decimal Total);
// PROPERTY patterns match against an object's properties using { }.
static string ShippingNote(Order o) => o switch
{
{ Status: "cancelled" } => "No shipping",
{ Total: > 50 } => "Free shipping",
{ Status: "pending", Total: <= 50 } => "Standard £4.99",
_ => "Standard £4.99"
};
static void Main()
{
Console.WriteLine(ShippingNote(new Order("paid", 80))); // Free shipping
Console.WriteLine(ShippingNote(new Order("cancelled", 99))); // No shipping
Console.WriteLine(ShippingNote(new Order("pending", 20))); // Standard £4.99
}
}
// ✅ Expected output:
// Free shipping
// No shipping
// Standard £4.99🧩 Reorder Challenge
These switch arms are scrambled. Reorder them so the expression correctly classifies a temperature and is never unreachable.
A. _ => "warm"
B. string Weather(int t) => t switch
C. < 0 => "freezing"
D. {
E. >= 30 => "hot"
F. < 15 => "cold"
G. };B. string Weather(int t) => t switch
D. {
C. < 0 => "freezing"
F. < 15 => "cold"
E. >= 30 => "hot"
A. _ => "warm"
G. };Most-specific relational patterns come first; < 0 must precede < 15 (a value like -5 matches both), and _ is always last.
🧠 Quick Recall
1. What does this print?
object o = 5;
Console.WriteLine(o is int n ? n * 2 : -1);10 — o is an int, so it's captured as n (5) and doubled.
2. What does this print?
int x = 80;
Console.WriteLine(x switch { >= 90 => "A", >= 60 => "B", _ => "F" });B — 80 isn't >= 90 but is >= 60, so the second arm wins.
3. What does this print?
int n = 6;
Console.WriteLine(n switch { > 0 when n % 2 == 0 => "even+", _ => "other" });even+ — 6 is positive and even, so the guarded arm matches.
Putting It Together: a Shape Area Calculator
Type patterns, property patterns and the null pattern combine into a clean, branch-free area calculator. Compare this to the if (shape is Circle) ... else if ... version you'd have written before.
using System;
class Program
{
// A tiny shape area calculator using TYPE + PROPERTY patterns together.
record Circle(double Radius);
record Rectangle(double Width, double Height);
record Square(double Side);
static double Area(object shape) => shape switch
{
Circle c => Math.PI * c.Radius * c.Radius,
Rectangle { Width: var w, Height: var h } => w * h,
Square s => s.Side * s.Side,
null => 0,
_ => throw new ArgumentException("Unknown shape")
};
static void Main()
{
Console.WriteLine($"Circle: {Area(new Circle(2)):F2}"); // 12.57
Console.WriteLine($"Rectangle: {Area(new Rectangle(3, 4))}"); // 12
Console.WriteLine($"Square: {Area(new Square(5))}"); // 25
}
}
// ✅ Expected output:
// Circle: 12.57
// Rectangle: 12
// Square: 25Pro Tips
- 💡 Order arms from most to least specific — the first match wins, so a broad pattern above a narrow one makes the narrow one unreachable.
- 💡 Always include a _ arm in a switch expression so it's exhaustive and never throws at runtime.
- 💡 Combine patterns: Order { Total: > 100 } o matches the type, checks a property, and captures the object all at once.
- 💡 Use and/or/not in patterns: is >= 0 and <= 100 reads beautifully for range checks.
Common Errors (and the fix)
- "CS8509: switch expression does not handle all possible values": add a _ arm to cover everything else.
- "The pattern is unreachable": a broader arm above is swallowing this one — move the specific arm higher.
- Using case/break in a switch expression: that's the old statement syntax. Expressions use => and commas only.
- Forgetting the comma between arms: each arm ends with a comma, including before _.
📋 Quick Reference
| Pattern | Example | Matches |
|---|---|---|
| Type | is string s | a string, captured as s |
| Constant | is 42 | exactly 42 |
| Relational | >= 90 | 90 or more |
| Logical | >= 0 and <= 9 | a single digit |
| Property | { Total: > 50 } | Total over 50 |
| Discard | _ | anything (default) |
Mini-Challenge: HTTP Status Describer
Write a switch expression that maps status codes to friendly text. Mind the ordering of your relational patterns!
using System;
class Program
{
static void Main()
{
// 🎯 MINI-CHALLENGE: HTTP status describer
// Write a switch EXPRESSION that maps an int status code to text:
// 200 => "OK"
// 404 => "Not Found"
// >= 500 => "Server Error"
// >= 400 => "Client Error"
// _ => "Unknown"
// Then print Describe(200), Describe(404), Describe(503), Describe(418).
//
// ✅ Example output:
// OK
// Not Found
// Server Error
// Client Error
// your code here
}
}🎉 Lesson Complete
- ✅ is tests a value's shape and can capture it
- ✅ Switch expressions return a value with => arms — no break
- ✅ Relational (>=), logical (and/or/not) and property patterns
- ✅ when guards add extra conditions to an arm
- ✅ Order arms specific-to-general; end with _ for exhaustiveness
Practice quiz
What does 'value is string s' do?
- Converts value to a string
- Always returns true
- Tests if value is a string and captures it as s
- Compares value to the text 'string'
Answer: Tests if value is a string and captures it as s. The 'is' type pattern tests the shape and captures the matched value into a new variable s.
How do switch expression arms differ from old switch statement cases?
- They use => arrows and commas, with no case or break
- They use 'case' and 'break'
- They require a loop
- They cannot return a value
Answer: They use => arrows and commas, with no case or break. A switch expression returns a value using => arms separated by commas — no case, no break.
In a switch expression, which arm runs when several patterns could match?
- The most specific one anywhere
- All matching arms
- The last arm
- The first matching arm, top to bottom
Answer: The first matching arm, top to bottom. Arms are tested top to bottom and the first match wins, so order matters.
What does the discard pattern _ match?
- Only null
- Anything else (the default)
- Only zero
- Nothing
Answer: Anything else (the default). _ is the discard/default pattern — it matches anything not handled above and is usually placed last.
What does a 'when' guard add to a pattern arm?
- An extra boolean condition on top of the pattern
- A loop
- A type cast
- A default value
Answer: An extra boolean condition on top of the pattern. A 'when' clause adds an arbitrary boolean test; the arm matches only if both the pattern and the guard are true.
Do switch expression arms fall through like C or JavaScript cases?
- Yes, always
- Only with break
- No — exactly one arm runs, with no fall-through or break
- Only for numbers
Answer: No — exactly one arm runs, with no fall-through or break. There is no fall-through; exactly one arm (the first match) runs and its value is returned.
What does the property pattern '{ Total: > 50 }' match?
- Any object
- An object whose Total property is greater than 50
- A number equal to 50
- A list of 50 items
Answer: An object whose Total property is greater than 50. Property patterns match against an object's properties using braces; this matches when Total is over 50.
What does the compiler do if a switch expression isn't exhaustive and has no _ arm?
- Silently ignores it
- Refuses to compile entirely with no message
- Adds a default automatically
- Warns it may not handle all values (and it can throw at runtime)
Answer: Warns it may not handle all values (and it can throw at runtime). It warns the expression is non-exhaustive; adding a _ arm resolves it and prevents a runtime throw.
Which logical pattern reads as a range check for a single digit?
- 0 or 9
- >= 0 and <= 9
- not 0
- is digit
Answer: >= 0 and <= 9. Logical patterns combine with and/or/not; '>= 0 and <= 9' matches a single digit.
Why must '< 0' come before '< 15' in a relational switch expression?
- Alphabetical order
- It doesn't matter
- Because a value like -5 matches both, and the first match wins
- Negative numbers are illegal otherwise
Answer: Because a value like -5 matches both, and the first match wins. Order specific-to-general: -5 matches both, so the more specific '< 0' must come first or it becomes unreachable.
Continue this course
- Previous: Nullable Types & Null Operators
- Next: Records — Concise immutable data types with value equality and with-expressions
- Quick reference: C# cheat sheet › Basics
Frequently asked questions
What's the difference between a switch statement and a switch expression?
A switch statement runs blocks of code with 'case' and 'break'. A switch expression RETURNS a value using => arrows and commas, with no break needed. Use the expression form whenever you're producing a single value.
What does the underscore _ mean?
It's the discard / default pattern — 'match anything else'. It's the switch-expression equivalent of 'default:' and should usually be the last arm so it catches everything not handled above.
Do switch expression arms fall through like in C or JavaScript?
No. There's no fall-through and no break. Exactly one arm runs — the first one (top to bottom) whose pattern matches — and its value is returned.
What is a 'when' guard for?
A 'when' clause adds an arbitrary boolean condition on top of a pattern, e.g. '> 0 when n % 2 == 0'. Use it when the pattern alone can't express the test you need.
Will the compiler warn me if I miss a case?
For switch expressions, yes — if you don't cover every possibility (and have no _), the compiler warns the expression isn't exhaustive and may throw at runtime. Adding a '_' arm resolves it.