You are viewing a preview of this lesson. Sign in to start learning
Back to C# Programming

Pattern Matching

Master modern switch expressions and pattern matching for elegant control flow

Pattern Matching in C#

Master pattern matching in C# with free flashcards and spaced repetition practice. This lesson covers declaration patterns, type patterns, property patterns, relational patterns, and logical patternsβ€”essential concepts for writing expressive and maintainable modern C# code.

Welcome to Pattern Matching! πŸ’»

Pattern matching is one of the most powerful features introduced in modern C#. Starting from C# 7.0 and expanding significantly through C# 8.0, 9.0, 10.0, and beyond, pattern matching allows you to test whether a value has a certain "shape" and extract information from it when it does. This makes your code more readable, concise, and less error-prone compared to traditional if-else chains or type casting.

Why Pattern Matching Matters:

  • 🎯 More Expressive: Write code that clearly states your intent
  • πŸ”’ Type-Safe: Compiler-verified type checks and conversions
  • πŸš€ Less Boilerplate: Eliminate repetitive casting and null checks
  • 🧩 Better Maintainability: Easier to understand and modify complex conditional logic

Core Concepts 🧠

What is Pattern Matching?

At its core, pattern matching allows you to test a value against a pattern and conditionally extract information from that value. Think of it as asking questions about your data: "Is this a string?", "Does this object have a Length property greater than 5?", "Is this value between 1 and 10?".

The Evolution of Pattern Matching

C# VersionPattern Types Introduced
C# 7.0Type patterns, constant patterns, var patterns
C# 8.0Property patterns, tuple patterns, positional patterns
C# 9.0Relational patterns, logical patterns (and, or, not)
C# 10.0Extended property patterns, list patterns
C# 11.0List pattern improvements

Type Patterns πŸ”

Type patterns test whether a value is of a specific type and, if so, convert it to that type.

Syntax: expression is Type variableName

Before pattern matching, you'd write:

if (obj is string)
{
    string s = (string)obj;
    Console.WriteLine(s.ToUpper());
}

With type patterns:

if (obj is string s)
{
    Console.WriteLine(s.ToUpper());
}

The variable s is automatically declared and scoped to the if block when the pattern matches.

Declaration Patterns πŸ“‹

Declaration patterns combine type testing with variable declaration. They're the foundation of many other pattern types.

public static string GetDescription(object obj)
{
    return obj switch
    {
        int i => $"Integer: {i}",
        string s => $"String of length {s.Length}",
        DateTime dt => $"Date: {dt:yyyy-MM-dd}",
        null => "Null value",
        _ => "Unknown type"
    };
}

πŸ’‘ Tip: The switch expression (introduced in C# 8.0) is often cleaner than traditional switch statements for pattern matching.

Constant Patterns 🎲

Constant patterns test whether a value equals a specific constant.

public static bool IsWeekend(DayOfWeek day)
{
    return day is DayOfWeek.Saturday or DayOfWeek.Sunday;
}

Property Patterns 🏠

Property patterns let you match on properties of an object. This is incredibly powerful for working with complex data structures.

Syntax: expression is Type { Property: pattern, ... }

public class Person
{
    public string Name { get; set; }
    public int Age { get; set; }
    public string City { get; set; }
}

public static string CheckPerson(Person person)
{
    return person switch
    {
        { Age: < 18 } => "Minor",
        { Age: >= 18, Age: < 65 } => "Adult",
        { Age: >= 65 } => "Senior",
        null => "No person"
    };
}

You can nest property patterns:

public class Order
{
    public Customer Customer { get; set; }
    public decimal Total { get; set; }
}

public class Customer
{
    public string Name { get; set; }
    public bool IsPremium { get; set; }
}

public static decimal CalculateDiscount(Order order)
{
    return order switch
    {
        { Customer: { IsPremium: true }, Total: > 1000 } => 0.15m,
        { Customer: { IsPremium: true }, Total: > 500 } => 0.10m,
        { Customer: { IsPremium: true } } => 0.05m,
        { Total: > 1000 } => 0.10m,
        _ => 0m
    };
}

Relational Patterns βš–οΈ

Relational patterns (C# 9.0+) use comparison operators: <, <=, >, >=.

public static string GetTemperatureDescription(int celsius)
{
    return celsius switch
    {
        < 0 => "Freezing",
        >= 0 and < 10 => "Cold",
        >= 10 and < 20 => "Cool",
        >= 20 and < 30 => "Warm",
        >= 30 => "Hot"
    };
}

Logical Patterns πŸ”€

Logical patterns (C# 9.0+) combine patterns using and, or, and not.

public static bool IsValidAge(int age)
{
    return age is >= 0 and <= 120;
}

public static string ClassifyNumber(int number)
{
    return number switch
    {
        0 => "Zero",
        > 0 and < 10 => "Single digit positive",
        < 0 and > -10 => "Single digit negative",
        >= 10 or <= -10 => "Multiple digits"
    };
}

public static bool IsNotNull(object obj)
{
    return obj is not null;
}

Positional Patterns πŸ“

For types that support deconstruction (like tuples or types with a Deconstruct method), you can use positional patterns.

public static string GetQuadrant(double x, double y)
{
    return (x, y) switch
    {
        (0, 0) => "Origin",
        (> 0, > 0) => "Quadrant I",
        (< 0, > 0) => "Quadrant II",
        (< 0, < 0) => "Quadrant III",
        (> 0, < 0) => "Quadrant IV",
        (_, 0) => "X-axis",
        (0, _) => "Y-axis"
    };
}

List Patterns πŸ“š

List patterns (C# 11.0+) allow you to match sequences and arrays.

public static string DescribeArray(int[] array)
{
    return array switch
    {
        [] => "Empty array",
        [1] => "Array with single element: 1",
        [1, 2] => "Array: [1, 2]",
        [1, ..] => "Array starting with 1",
        [.., 9] => "Array ending with 9",
        [1, .., 9] => "Array starting with 1 and ending with 9",
        _ => "Other array"
    };
}

The .. syntax is called a slice pattern and matches zero or more elements.

Var Patterns πŸ“¦

Var patterns always match and assign the value to a variable. They're useful when you need to capture a value for further processing.

public static bool IsLongString(object obj)
{
    return obj is var value && value is string s && s.Length > 10;
}

Detailed Examples with Explanations πŸŽ“

Example 1: Building a Shape Calculator πŸ”Ί

Let's create a shape hierarchy and use pattern matching to calculate areas:

public abstract class Shape { }

public class Circle : Shape
{
    public double Radius { get; set; }
}

public class Rectangle : Shape
{
    public double Width { get; set; }
    public double Height { get; set; }
}

public class Triangle : Shape
{
    public double Base { get; set; }
    public double Height { get; set; }
}

public static double CalculateArea(Shape shape)
{
    return shape switch
    {
        Circle { Radius: var r } => Math.PI * r * r,
        Rectangle { Width: var w, Height: var h } => w * h,
        Triangle { Base: var b, Height: var h } => 0.5 * b * h,
        null => throw new ArgumentNullException(nameof(shape)),
        _ => throw new ArgumentException("Unknown shape type")
    };
}

public static string DescribeShape(Shape shape)
{
    return shape switch
    {
        Circle { Radius: > 10 } => "Large circle",
        Circle { Radius: <= 10 and > 5 } => "Medium circle",
        Circle { Radius: <= 5 } => "Small circle",
        Rectangle { Width: var w, Height: var h } when w == h => "Square",
        Rectangle { Width: > 20, Height: > 20 } => "Large rectangle",
        Rectangle => "Rectangle",
        Triangle { Base: > 0, Height: > 0 } => "Valid triangle",
        _ => "Unknown shape"
    };
}

Explanation:

  • Property patterns extract the Radius, Width, and Height values
  • Relational patterns (> 10, <= 10 and > 5) classify circles by size
  • The when clause adds additional boolean conditions
  • The var keyword in property patterns captures the value for use in the expression

Example 2: HTTP Response Handler 🌐

Pattern matching shines when handling different HTTP status codes:

public record HttpResponse(int StatusCode, string Body, Dictionary<string, string> Headers);

public static string HandleResponse(HttpResponse response)
{
    return response switch
    {
        { StatusCode: 200, Body: { Length: > 0 } } 
            => $"Success: {response.Body}",
        
        { StatusCode: 200, Body: "" } 
            => "Success but empty response",
        
        { StatusCode: >= 200 and < 300 } 
            => "Successful request",
        
        { StatusCode: 301 or 302, Headers: var h } when h.ContainsKey("Location") 
            => $"Redirect to: {h["Location"]}",
        
        { StatusCode: 400 } 
            => "Bad request",
        
        { StatusCode: 401 or 403 } 
            => "Unauthorized or forbidden",
        
        { StatusCode: 404 } 
            => "Resource not found",
        
        { StatusCode: >= 400 and < 500 } 
            => "Client error",
        
        { StatusCode: >= 500 } 
            => "Server error",
        
        _ => "Unknown status code"
    };
}

Explanation:

  • Combines property patterns with relational patterns
  • Uses logical or to handle multiple status codes
  • The when guard checks dictionary contents
  • Nested property patterns check Body.Length

Example 3: Investment Portfolio Analyzer πŸ’°

A real-world example analyzing investment types:

public abstract class Investment
{
    public decimal Amount { get; set; }
    public DateTime PurchaseDate { get; set; }
}

public class Stock : Investment
{
    public string Symbol { get; set; }
    public decimal SharePrice { get; set; }
}

public class Bond : Investment
{
    public decimal InterestRate { get; set; }
    public DateTime MaturityDate { get; set; }
}

public class RealEstate : Investment
{
    public string Address { get; set; }
    public decimal RentalIncome { get; set; }
}

public static string AnalyzeInvestment(Investment investment, DateTime currentDate)
{
    var holdingPeriod = (currentDate - investment.PurchaseDate).Days;
    
    return investment switch
    {
        Stock { Amount: > 100000, SharePrice: > 500 } 
            => "High-value tech stock position",
        
        Stock { Symbol: "AAPL" or "MSFT" or "GOOGL" } 
            => "Major tech company stock",
        
        Stock s when holdingPeriod > 365 
            => $"Long-term stock holding: {s.Symbol}",
        
        Bond { InterestRate: > 0.05m, MaturityDate: var md } when md > currentDate 
            => "High-yield bond, not yet mature",
        
        Bond { MaturityDate: var md } when md <= currentDate 
            => "Matured bond - action required",
        
        RealEstate { RentalIncome: > 0, Amount: > 500000 } 
            => "High-value income-generating property",
        
        RealEstate { RentalIncome: 0 } 
            => "Non-income property (appreciation play)",
        
        { Amount: < 10000 } 
            => "Small investment position",
        
        _ => "Standard investment"
    };
}

Explanation:

  • Combines multiple pattern types in a single switch expression
  • Uses when clauses for complex time-based logic
  • Property patterns with logical or for multiple stock symbols
  • The base class property Amount is accessible in all patterns

Example 4: Recursive Data Structure Processing 🌳

Pattern matching is excellent for processing tree-like structures:

public abstract class JsonValue { }

public class JsonObject : JsonValue
{
    public Dictionary<string, JsonValue> Properties { get; set; }
}

public class JsonArray : JsonValue
{
    public List<JsonValue> Items { get; set; }
}

public class JsonString : JsonValue
{
    public string Value { get; set; }
}

public class JsonNumber : JsonValue
{
    public double Value { get; set; }
}

public class JsonBoolean : JsonValue
{
    public bool Value { get; set; }
}

public class JsonNull : JsonValue { }

public static int CountValues(JsonValue json)
{
    return json switch
    {
        JsonNull => 0,
        JsonString or JsonNumber or JsonBoolean => 1,
        JsonArray { Items: var items } => items.Sum(CountValues),
        JsonObject { Properties: var props } => props.Values.Sum(CountValues),
        _ => 0
    };
}

public static string Stringify(JsonValue json, int indent = 0)
{
    var spaces = new string(' ', indent * 2);
    
    return json switch
    {
        JsonNull => "null",
        JsonBoolean { Value: var b } => b.ToString().ToLower(),
        JsonNumber { Value: var n } => n.ToString(),
        JsonString { Value: var s } => $"\"{s}\"",
        
        JsonArray { Items: [] } => "[]",
        JsonArray { Items: [var single] } => $"[{Stringify(single)}]",
        JsonArray { Items: var items } => 
            $"[\n{spaces}  " + 
            string.Join($",\n{spaces}  ", items.Select(i => Stringify(i, indent + 1))) + 
            $"\n{spaces}]",
        
        JsonObject { Properties: var props } when props.Count == 0 => "{}",
        JsonObject { Properties: var props } =>
            "{\n" + 
            string.Join(",\n", props.Select(kvp => 
                $"{spaces}  \"{kvp.Key}\": {Stringify(kvp.Value, indent + 1)}")) + 
            $"\n{spaces}}}",
        
        _ => "undefined"
    };
}

Explanation:

  • List patterns with [] match empty arrays
  • List patterns with [var single] match single-element arrays
  • Recursive calls within pattern match arms
  • Logical or combines multiple simple types
  • when clauses check collection sizes

Common Mistakes ⚠️

1. Forgetting Pattern Order Matters

❌ Wrong:

return value switch
{
    _ => "Default",                    // This matches everything!
    > 0 and < 10 => "Single digit",   // Never reached
    >= 10 => "Multiple digits"         // Never reached
};

βœ… Correct:

return value switch
{
    > 0 and < 10 => "Single digit",
    >= 10 => "Multiple digits",
    _ => "Default"                     // Always last
};

Why: Patterns are evaluated top-to-bottom. The first matching pattern wins. The discard pattern _ matches everything, so it must come last.

2. Not Handling All Cases

❌ Wrong:

return shape switch
{
    Circle c => CalculateCircleArea(c),
    Rectangle r => CalculateRectangleArea(r)
    // Compiler warning: not all cases handled!
};

βœ… Correct:

return shape switch
{
    Circle c => CalculateCircleArea(c),
    Rectangle r => CalculateRectangleArea(r),
    _ => throw new ArgumentException("Unknown shape")
};

Why: The compiler expects exhaustive pattern matching. Always include a default case with _ unless you've covered all possibilities.

3. Incorrect Null Handling

❌ Wrong:

return obj switch
{
    string s when s.Length > 5 => "Long string",  // NullReferenceException if obj is null!
    _ => "Other"
};

βœ… Correct:

return obj switch
{
    null => "Null value",
    string s when s.Length > 5 => "Long string",
    string s => "Short string",
    _ => "Other"
};

Why: Always check for null first, or use not null pattern to ensure safety.

4. Overcomplicating with When Clauses

❌ Wrong:

return person switch
{
    { Age: var a } when a >= 18 && a < 65 => "Adult",  // Unnecessary
    _ => "Other"
};

βœ… Correct:

return person switch
{
    { Age: >= 18 and < 65 } => "Adult",  // Cleaner with relational patterns
    _ => "Other"
};

Why: Use relational and logical patterns instead of when clauses when possible. They're more readable and efficient.

5. Forgetting Type Safety

❌ Wrong:

if (obj is string s)
{
    // ... many lines of code ...
}
Console.WriteLine(s.ToUpper());  // Compiler error: s not in scope!

βœ… Correct:

if (obj is string s)
{
    Console.WriteLine(s.ToUpper());  // Use s within the scope
}

Why: Pattern variables are scoped to their containing block. They're not accessible outside.

6. Not Using Property Patterns Effectively

❌ Wrong:

return order switch
{
    Order o when o.Customer != null && o.Customer.IsPremium && o.Total > 1000 => 0.15m,
    _ => 0m
};

βœ… Correct:

return order switch
{
    { Customer.IsPremium: true, Total: > 1000 } => 0.15m,
    _ => 0m
};

Why: Property patterns are more concise and handle null checking automatically at each level.

7. Mixing Switch Statement with Switch Expression Syntax

❌ Wrong:

string result = value switch
{
    > 0 => "Positive";     // Semicolon instead of comma!
    < 0 => "Negative";     // Switch expressions use =>, not :
    _ => "Zero"
};

βœ… Correct:

string result = value switch
{
    > 0 => "Positive",     // Comma separator
    < 0 => "Negative",
    _ => "Zero"            // No semicolon on last one
};

Why: Switch expressions use => and commas, not colons and semicolons like switch statements.

🧠 Memory Tips & Tricks

🎯 The PRCLV Mnemonic for Pattern Types:

  • Property patterns - check object properties
  • Relational patterns - use comparison operators
  • Constant patterns - match specific values
  • Logical patterns - combine with and/or/not
  • Var patterns - capture any value

πŸ’‘ Pattern Matching Decision Flow:

β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚  Need to check a value?                 β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
             β”‚
             β–Ό
    Is it a type check?
             β”‚
      β”Œβ”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”
      β”‚             β”‚
     YES           NO
      β”‚             β”‚
      β–Ό             β–Ό
  Use "is Type  Need properties?
   variable"        β”‚
                    β–Ό
            Use property pattern
             { Prop: pattern }

πŸ”§ Quick Reference: When to Use Each Pattern

πŸ“‹ Pattern Matching Quick Reference

Use CasePattern TypeExample
Check typeType patternobj is string s
Check specific valueConstant patternx is 42
Check object propertiesProperty pattern{ Age: > 18 }
Compare numbersRelational patternx is >= 0 and < 10
Combine conditionsLogical patternx is > 0 or < -5
Match tuple valuesPositional pattern(x, y) is (0, 0)
Match list elementsList pattern[1, .., 9]
Capture valueVar patternis var x
Default caseDiscard pattern_

🎯 Real-World Scenarios

Scenario 1: API Response Handling

You're building a REST API client and need to handle different response types elegantly:

public async Task<Result> FetchDataAsync(string endpoint)
{
    var response = await httpClient.GetAsync(endpoint);
    
    return (response.StatusCode, await response.Content.ReadAsStringAsync()) switch
    {
        (HttpStatusCode.OK, var content) when !string.IsNullOrEmpty(content) 
            => Result.Success(content),
        
        (HttpStatusCode.OK, _) 
            => Result.Failure("Empty response"),
        
        (HttpStatusCode.NotFound, _) 
            => Result.Failure("Resource not found"),
        
        (HttpStatusCode.Unauthorized or HttpStatusCode.Forbidden, _) 
            => Result.Failure("Access denied"),
        
        (var code, _) when (int)code >= 500 
            => Result.Failure("Server error"),
        
        _ => Result.Failure("Unknown error")
    };
}

Scenario 2: Game State Management

In a game, you need to determine valid player actions based on game state:

public record GameState(int Health, int Ammo, bool HasKey, string Location);

public static List<string> GetAvailableActions(GameState state)
{
    return state switch
    {
        { Health: <= 0 } 
            => new List<string> { "Respawn", "Quit" },
        
        { Health: < 20, Location: "Hospital" } 
            => new List<string> { "Heal", "Rest", "Leave" },
        
        { Ammo: > 0, Health: > 20 } 
            => new List<string> { "Shoot", "Move", "Reload", "Inventory" },
        
        { Ammo: 0, Health: > 20 } 
            => new List<string> { "Move", "FindAmmo", "Inventory" },
        
        { HasKey: true, Location: "LockedDoor" } 
            => new List<string> { "UnlockDoor", "Move", "Inventory" },
        
        { Location: "SafeRoom" } 
            => new List<string> { "Rest", "SaveGame", "ManageInventory", "Leave" },
        
        _ => new List<string> { "Move", "Inventory" }
    };
}

Key Takeaways πŸŽ“

  1. Pattern matching makes code more expressive: Instead of nested if-else chains, express your intent directly

  2. Order matters: Patterns are evaluated top-to-bottom; more specific patterns should come before general ones

  3. Always handle all cases: Use the discard pattern _ as a catch-all to ensure exhaustiveness

  4. Combine pattern types: Mix property, relational, and logical patterns for powerful expressions

  5. Prefer switch expressions over switch statements: They're more concise and enforce returning a value

  6. Use property patterns for object inspection: They're cleaner than manual property access and null checks

  7. Relational and logical patterns (C# 9.0+) reduce when clauses: Write x is >= 0 and < 10 instead of x when x >= 0 && x < 10

  8. List patterns (C# 11.0+) simplify sequence matching: Great for matching arrays and lists by structure

  9. Type safety comes free: Pattern variables are strongly typed and scoped appropriately

  10. Performance is good: The compiler optimizes pattern matching efficiently

πŸ“š Further Study


πŸŽ‰ Congratulations! You've mastered the fundamentals of pattern matching in C#. This powerful feature will make your code more maintainable, type-safe, and expressive. Practice by refactoring existing if-else chains and type casting code into pattern matching expressions!