📝 Lesson 3.2: Pattern Matching
Pattern matching lets you test the shape and content of data — its type, its properties, its values — in remarkably concise ways. It turns sprawling if/else chains into clear, expressive code.
🎯 Learning Objectives
By the end of this lesson, you will be able to:
- Use type patterns with
isto test and cast in one step - Write
switchexpressions that return values - Match on properties, ranges (relational), and combinations (logical)
- Match and deconstruct tuples and records
- Recognize when pattern matching makes code clearer
Estimated Time: 60 minutes
Project: Replace tangled conditionals with clean pattern-matching code.
In This Lesson
What Is Pattern Matching?
You already know the basic switch from the intro course, which matched a value against constant cases. Pattern matching is a big upgrade: instead of only "is this value equal to X?", you can ask "does this value match this shape?" — its type, a property, a range, or a combination.
📖 Definition
Pattern matching: testing whether a value matches a pattern — a description of a type, structure, or condition — often extracting data from it at the same time.
The payoff is code that reads like the rules it implements. Compare a nested if chain with a pattern-based switch expression:
// Before: nested if/else
string Describe(int n)
{
if (n < 0) return "negative";
else if (n == 0) return "zero";
else if (n < 10) return "small";
else return "large";
}
// After: a switch expression with patterns
string Describe(int n) => n switch
{
< 0 => "negative",
0 => "zero",
< 10 => "small",
_ => "large"
};
Type Patterns with is
The is operator tests whether a value is a particular type — and can assign it to a new variable in the same step, so you don't cast separately:
object value = "hello";
if (value is string text) // is it a string? if so, put it in 'text'
{
Console.WriteLine(text.ToUpper()); // HELLO — 'text' is a real string here
}
This is much cleaner than the old test-then-cast dance. It shines when handling values that could be one of several types (recall polymorphism from the intro):
void Print(object item)
{
if (item is int n)
{
Console.WriteLine($"An integer: {n}");
}
else if (item is string s)
{
Console.WriteLine($"A string of length {s.Length}");
}
else
{
Console.WriteLine("Something else");
}
}
💡 Combine with not
A very common, readable null check uses the is pattern: if (item is not null) { ... }. You'll see this style everywhere in modern C#.
Switch Expressions
A switch expression (introduced in the intro course) produces a value, and it's where pattern matching really shines. Its arms use pattern => result, and _ is the catch-all:
string DescribeType(object item) => item switch
{
int n => $"int: {n}",
string s => $"string: {s}",
bool b => $"bool: {b}",
null => "null",
_ => "unknown"
};
Console.WriteLine(DescribeType(42)); // int: 42
Console.WriteLine(DescribeType("hi")); // string: hi
Console.WriteLine(DescribeType(true)); // bool: True
⚠️ Handle every case (exhaustiveness)
A switch expression must produce a value for any input. If no arm matches and there's no _, it throws at runtime. Including a _ catch-all arm keeps your switch exhaustive and safe.
which pattern?"} S -->|"int n"| R1["int result"] S -->|"string s"| R2["string result"] S -->|"_ (any)"| R3["default result"] style A fill:#eff6ff,stroke:#3b82f6,stroke-width:2px style R1 fill:#e8f5e9,stroke:#4CAF50,stroke-width:2px style R2 fill:#e8f5e9,stroke:#4CAF50,stroke-width:2px style R3 fill:#e8f5e9,stroke:#4CAF50,stroke-width:2px
Relational & Logical Patterns
Relational patterns match against ranges using <, >, <=, >=. Logical patterns combine patterns with and, or, and not. Together they express ranges beautifully:
string Grade(int score) => score switch
{
>= 90 => "A",
>= 80 => "B",
>= 70 => "C",
>= 60 => "D",
_ => "F"
};
string Category(int age) => age switch
{
< 0 => "invalid",
>= 0 and < 13 => "child", // 'and' combines two relational patterns
>= 13 and < 20 => "teenager",
_ => "adult"
};
Console.WriteLine(Grade(85)); // B
Console.WriteLine(Category(15)); // teenager
✅ Remember the grade example?
In the intro course you wrote grades as an if/else if chain. The relational-pattern version above does the same thing, but the intent — a set of ranges — is far more visible at a glance. That's the whole appeal of pattern matching.
Logical or and not are handy too:
bool IsWeekend(string day) => day switch
{
"Saturday" or "Sunday" => true,
_ => false
};
Property Patterns
Property patterns match against an object's properties using braces. This lets you branch on the contents of an object, not just its type:
record Order(string Product, int Quantity, decimal Total);
string Classify(Order order) => order switch
{
{ Total: > 1000m } => "big order",
{ Quantity: > 100 } => "bulk order",
{ Product: "Gift Card" } => "gift card",
_ => "standard order"
};
Console.WriteLine(Classify(new Order("Laptop", 1, 1500m))); // big order
Console.WriteLine(Classify(new Order("Pen", 500, 250m))); // bulk order
You can nest and combine them, and even capture a value with var for use in the result:
string Ship(Order order) => order switch
{
{ Total: > 500m } => "free express shipping",
{ Quantity: > 1, Product: var p } => $"standard shipping for {p}",
_ => "standard shipping"
};
💡 Pairs perfectly with records
Property patterns and records (Lesson 3.1) are natural partners: records give you clean, immutable data, and property patterns let you branch on that data expressively.
Tuple Patterns
You can switch on multiple values at once by grouping them in a tuple. This is perfect for decisions that depend on a combination of inputs:
string Rps(string player, string opponent) => (player, opponent) switch
{
("rock", "scissors") => "win",
("paper", "rock") => "win",
("scissors", "paper") => "win",
(var a, var b) when a == b => "tie", // 'when' adds an extra condition
_ => "lose"
};
Console.WriteLine(Rps("rock", "scissors")); // win
Console.WriteLine(Rps("rock", "rock")); // tie
Console.WriteLine(Rps("rock", "paper")); // lose
💡 The when guard
A when clause adds an arbitrary boolean condition to a pattern arm — useful when the pattern alone can't express the rule (like "the two values are equal"). Use it sparingly; often a more specific pattern is clearer.
Records deconstruct into tuple-like patterns too, so you can match their parts directly:
record Point(int X, int Y);
string Where(Point p) => p switch
{
(0, 0) => "origin",
(var x, 0) => $"on X axis at {x}",
(0, var y) => $"on Y axis at {y}",
_ => "somewhere else"
};
Console.WriteLine(Where(new Point(0, 0))); // origin
Console.WriteLine(Where(new Point(5, 0))); // on X axis at 5
Exercise & Quiz
🏋️ Exercise: A Fare Calculator
Objective: Use switch expressions with relational, logical, and property patterns.
Instructions:
- Create a new project called
Patterns. - Write
string TicketPrice(int age)using a switch expression: under 5 → "free", 5–17 → "child", 18–64 → "adult", 65+ → "senior". Use relational and logical patterns. - Define
record Passenger(int Age, bool HasPass);and writedecimal Fare(Passenger p)using property patterns: anyone withHasPass = truepays 0; otherwise under 18 pays 5m; otherwise 10m. - Test each with a few values.
Starter Code:
Console.WriteLine(TicketPrice(3)); // free
Console.WriteLine(TicketPrice(40)); // adult
Console.WriteLine(Fare(new Passenger(30, true))); // 0
Console.WriteLine(Fare(new Passenger(12, false))); // 5
record Passenger(int Age, bool HasPass);
string TicketPrice(int age) => age switch
{
// TODO: relational/logical pattern arms
};
decimal Fare(Passenger p) => p switch
{
// TODO: property pattern arms
};
💡 Hint
For ranges: < 5 => "free", >= 5 and <= 17 => "child", etc. For property patterns: { HasPass: true } => 0m, then { Age: < 18 } => 5m, then _ => 10m. Order matters — the first match wins.
✅ Solution
Console.WriteLine(TicketPrice(3)); // free
Console.WriteLine(TicketPrice(40)); // adult
Console.WriteLine(Fare(new Passenger(30, true))); // 0
Console.WriteLine(Fare(new Passenger(12, false))); // 5
record Passenger(int Age, bool HasPass);
string TicketPrice(int age) => age switch
{
< 5 => "free",
>= 5 and <= 17 => "child",
>= 18 and <= 64 => "adult",
_ => "senior"
};
decimal Fare(Passenger p) => p switch
{
{ HasPass: true } => 0m,
{ Age: < 18 } => 5m,
_ => 10m
};
Output:
free
adult
0
5
🎯 Quick Quiz
Question 1: What does if (value is string text) do?
Question 2: In a switch expression, what does the _ arm represent?
Question 3: Which pattern matches an Order whose Total is over 1000?
Summary
🎉 Key Takeaways
- Pattern matching tests a value's type, structure, or condition — often extracting data at the same time.
- The
istype pattern tests and casts in one step (value is string text);is not nullis a common idiom. - Switch expressions return a value; include a
_arm to stay exhaustive. - Relational (
>=) and logical (and/or/not) patterns express ranges clearly; property patterns ({ Total: > 1000m }) branch on contents. - Tuple patterns match multiple values at once; a
whenguard adds an extra condition. Patterns pair beautifully with records.
📚 Additional Resources
🚀 What's Next?
You've made conditionals concise and safe. Next, we tackle one of the most common sources of runtime crashes head-on. In Lesson 3.3: Nullable Reference Types, you'll let the compiler help you avoid null errors before they happen.
🎉 Patterns mastered!
Your conditionals are now expressive and safe. Next: taming null.