Skip to main content

📝 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 is to test and cast in one step
  • Write switch expressions 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.

graph LR A["value"] --> S{"switch
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:

  1. Create a new project called Patterns.
  2. 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.
  3. Define record Passenger(int Age, bool HasPass); and write decimal Fare(Passenger p) using property patterns: anyone with HasPass = true pays 0; otherwise under 18 pays 5m; otherwise 10m.
  4. 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 is type pattern tests and casts in one step (value is string text); is not null is 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 when guard 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.