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

💡 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 value

2. 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 again

Your 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 odd

4. 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:    25

Pro Tips

Common Errors (and the fix)

📋 Quick Reference

PatternExampleMatches
Typeis string sa string, captured as s
Constantis 42exactly 42
Relational>= 9090 or more
Logical>= 0 and <= 9a 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

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

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.