C# Pattern Matching

Pattern matching lets C# test whether a value has a certain shape, type, value, or structure. Instead of writing long chains of casts, null checks, and nested if statements, you describe the case you are looking for directly in the code. It matters because modern C# uses patterns everywhere: is expressions, switch expressions, nullable checks, records, lists, and type-safe object handling.

Overview: How Pattern Matching Works

A pattern is a rule that C# uses to decide whether an input value matches. If the match succeeds, the pattern can also create variables that are safely available inside the matching branch. For example, value is string text checks that value is a string, assigns the string to text, and lets you use text without a separate cast.

Pattern matching is not a separate runtime system. The compiler lowers patterns into ordinary tests: null checks, type checks, comparisons, property reads, length checks, deconstruction calls, and branches. The CLR still executes normal IL. The benefit is that the compiler understands the intent, verifies unreachable cases where it can, narrows variable types after successful matches, and works with nullable flow analysis.

Patterns appear most often with the is operator and the switch expression or statement. The is operator answers a yes-or-no question, optionally introducing a variable. A switch tries a list of patterns in order and runs the first matching arm. Pattern order matters whenever one pattern is broader than another.

Modern C# includes many pattern forms. Constant patterns match exact values such as null, 0, or "admin". Declaration and type patterns test runtime type. Property patterns inspect named members. Positional patterns deconstruct records or types with a suitable Deconstruct method. Relational patterns compare values with operators such as > and <=. Logical patterns combine other patterns with and, or, and not. List patterns match arrays and list-like collections by element shape.

Because patterns can introduce variables only after a successful match, they are safer than manual casts. Because they integrate with nullability, if (customer is not null) tells the compiler that customer can be treated as non-null inside the block. That makes pattern matching one of the main tools for writing concise, null-safe C#.

Syntax

object value = "Order 42";

if (value is string text and { Length: > 0 })
{
    Console.WriteLine(text);
}

string category = value switch
{
    null => "missing",
    string { Length: 0 } => "empty text",
    string s => $"text: {s}",
    int and >= 0 => "non-negative number",
    _ => "other"
};

Console.WriteLine(category);
Pattern Example Meaning
Constant null, 42, "yes" Matches one specific value.
Declaration/type string text Matches a compatible runtime type and creates a variable.
Discard _ Matches anything and is often used as a fallback.
Property { Total: >= 100m } Matches when named properties match nested patterns.
Relational > 0, <= 10 Compares the input value to a constant.
Logical >= 0 and <= 100 Combines patterns using and, or, or not.
List [first, .., last] Matches arrays or list-like values by elements and length.

Examples

Example 1: Safe Type Checks With is

using System;

class Program
{
    static void Main()
    {
        object?[] values = { "CSharp", 42, null, "" };

        foreach (object? value in values)
        {
            if (value is string text and not "")
            {
                Console.WriteLine($"Text length: {text.Length}");
            }
            else if (value is int number and > 0)
            {
                Console.WriteLine($"Positive number: {number}");
            }
            else if (value is null)
            {
                Console.WriteLine("Missing value");
            }
            else
            {
                Console.WriteLine("Other value");
            }
        }
    }
}

Output:

Text length: 6
Positive number: 42
Missing value
Other value

The first branch matches only non-empty strings. When it succeeds, text is definitely a string, so text.Length is safe. The second branch combines a type pattern with a relational pattern. The null pattern handles missing values explicitly instead of relying on a cast that might fail.

Example 2: Property Patterns For Domain Rules

using System;

record Customer(string Name, bool IsVip, int YearsActive);
record Ticket(Customer Customer, decimal Total, string Status);

class Program
{
    static string Route(Ticket ticket) => ticket switch
    {
        { Status: "Closed" } => "Archive",
        { Customer: { IsVip: true }, Total: >= 500m } => "Senior support",
        { Customer: { YearsActive: >= 3 } } => "Loyalty team",
        { Total: < 25m } => "Self-service",
        _ => "Standard support"
    };

    static void Main()
    {
        Ticket[] tickets =
        {
            new Ticket(new Customer("Ada", true, 5), 800m, "Open"),
            new Ticket(new Customer("Ben", false, 4), 90m, "Open"),
            new Ticket(new Customer("Cy", false, 1), 12m, "Open"),
            new Ticket(new Customer("Dee", true, 8), 200m, "Closed")
        };

        foreach (Ticket ticket in tickets)
        {
            Console.WriteLine($"{ticket.Customer.Name}: {Route(ticket)}");
        }
    }
}

Output:

Ada: Senior support
Ben: Loyalty team
Cy: Self-service
Dee: Archive

Property patterns can be nested. The VIP rule checks ticket.Customer.IsVip and ticket.Total in one readable pattern. The closed-ticket rule comes first because it should override all routing rules. If it were last, a closed VIP ticket might be routed to support instead of archived.

Example 3: List Patterns For Sequences

using System;

class Program
{
    static string DescribeCommand(string[] parts) => parts switch
    {
        ["add", var item] => $"Add {item}",
        ["remove", var item] => $"Remove {item}",
        ["move", var item, "to", var destination] => $"Move {item} to {destination}",
        ["help", ..] => "Show help",
        [] => "No command",
        _ => "Unknown command"
    };

    static void Main()
    {
        string[][] commands =
        {
            new[] { "add", "book" },
            new[] { "move", "file", "to", "archive" },
            new[] { "help", "commands" },
            Array.Empty<string>(),
            new[] { "rename", "file" }
        };

        foreach (string[] command in commands)
        {
            Console.WriteLine(DescribeCommand(command));
        }
    }
}

Output:

Add book
Move file to archive
Show help
No command
Unknown command

List patterns match the number and shape of elements. The first two patterns require exactly two elements. The move pattern requires four elements and captures two of them. The .. slice pattern means the help command may have extra words after help.

How It Works Step By Step

  1. The input expression is evaluated once for an is pattern or a switch input.
  2. The compiler performs the tests required by the pattern. A declaration pattern may emit a runtime type check, a property pattern may emit null checks and property reads, and a relational pattern emits a comparison.
  3. If a pattern contains nested patterns, the outer value must match before inner members are inspected. A property pattern such as { Customer: { IsVip: true } } will not read IsVip unless Customer is non-null and matched.
  4. If the pattern introduces variables, those variables are definitely assigned only in the successful branch or switch arm.
  5. For logical patterns, not, and, and or follow pattern precedence rules. Use parentheses when a combination is not obvious.
  6. For a switch, arms are considered in source order and the first successful pattern wins.
  7. Nullable flow analysis uses successful patterns to narrow values. After if (name is not null), the compiler treats name as non-null inside that block.

Common Mistakes

Using var When You Meant A Type Check

The var pattern matches anything, including null. It is useful when you want to capture a value after another condition, but it is not the same as string text or int number.

object? value = null;

if (value is var captured)
{
    Console.WriteLine(captured.Length);
}

This code enters the if block because var captured matches any value. But captured is still an object?, not a string, so Length is not available. Use a type or null pattern when that is what you need.

using System;

class Program
{
    static void Main()
    {
        object? value = null;

        if (value is string text)
        {
            Console.WriteLine(text);
        }
        else
        {
            Console.WriteLine("No string value");
        }
    }
}

Output:

No string value

Putting A Broad Pattern Before A Specific Pattern

In a switch, the first matching arm wins. A broad pattern can make a later pattern unreachable or logically useless.

string label = score switch
{
    >= 0 => "valid score",
    >= 90 => "excellent",
    _ => "invalid"
};

The >= 90 arm can never be selected because every score greater than or equal to 90 also matches >= 0. Put specific patterns first.

using System;

class Program
{
    static void Main()
    {
        int score = 95;

        string label = score switch
        {
            >= 90 => "excellent",
            >= 0 => "valid score",
            _ => "invalid"
        };

        Console.WriteLine(label);
    }
}

Output:

excellent

Forgetting Parentheses In Logical Patterns

Logical patterns are readable when small, but complex combinations can be misread. Prefer parentheses when mixing and and or.

using System;

class Program
{
    static void Main()
    {
        int age = 17;
        bool hasPass = true;

        string access = (age, hasPass) switch
        {
            (>= 18, _) or (_, true) => "Allowed",
            _ => "Blocked"
        };

        Console.WriteLine(access);
    }
}

Output:

Allowed

The tuple pattern makes the two inputs explicit, and the parentheses make the rule clear: allow adults, or allow anyone with a pass. Without clear grouping, logical patterns can become harder to review than ordinary if statements.

Best Practices

  • Use is null and is not null for clear null checks that work well with nullable flow analysis.
  • Use declaration patterns such as string text instead of manual as casts followed by null checks.
  • Put specific patterns before broad patterns in switch arms.
  • Use property patterns when object member names make the rule clearer than positional data.
  • Use positional patterns for records and tuples when the positions are obvious and stable.
  • Use list patterns for command parsing, small protocol shapes, and sequence cases where length matters.
  • Keep patterns readable. If a pattern becomes very long, move part of the decision into a well-named method.
  • Use parentheses in logical patterns whenever precedence could distract a reader.
  • Always include a deliberate fallback, such as _, unless unmatched input should be treated as an error.

Practice Exercises

  1. Write a method that accepts object? and returns "empty string", "text", "positive integer", "null", or "other" using patterns.
  2. Create a Product record with Name, Price, and InStock. Use property patterns to label products as "featured", "budget", "unavailable", or "standard".
  3. Write a list-pattern command parser for ["copy", source, "to", destination], ["delete", target], and an unknown fallback.

Summary

  • Pattern matching lets C# test values by type, value, properties, position, sequence shape, and logical combinations.
  • The compiler lowers patterns into normal runtime checks while also improving type narrowing and nullable analysis.
  • is patterns are excellent for safe type checks and null checks.
  • switch patterns are tested in order, so the first matching arm wins.
  • Property, positional, relational, logical, and list patterns make modern C# branching more expressive.
  • Readable pattern matching removes many manual casts and nested checks, but overly complex patterns should be simplified.