Pattern Matching

The is operator and switch statement remained relatively static until C# 7.0 where declaration and significant pattern matching capabilities were added. Pattern matching provides a syntax through which criteria can be evaluated and the code execution branched accordingly. The C# team continued pattern matching enhancements all the way to C# 11.

In this section we will review all the pattern matching functionality: type patterns constant patterns (C# 7.0), relational patterns (C# 9.0), logical patterns (C# 9.0), tuple patterns (C# 8.0), positional patterns (C# 9.0), extended (C# 10) property patterns (C# 7.0), and list patterns (C# 11.0). All of these features leverage the recursive pattern capability that allows for multiple checks on a type to be made.

Constant Patterns (C# 7.0)

In Chapter 4, we demonstrated constant patterns when using the is operator to check for null (i.e., data is null) and not null and in explaining the basic switch statement (https://essentialcsharp.com/the-basic-switch-statement). The principle is that you can compare the input operand against any constant. You could, for example, compare data.Day against with the value 21. Listing 7.23 provides both is and switch expression examples.

Listing 7.23: Constant Pattern Matching with is Operator
using System.Diagnostics.CodeAnalysis;
 
public static class SolsticeHelper
{
    public static bool IsSolstice(
        DateTime date)
    {
        if (date.Day is 21)
        {
            if (date.Month is 12)
            {
                return true;
            }
            if (date.Month is 6)
            {
                return true;
            }
        }
        return false;
    }
 
    public static bool TryGetSolstice(DateTime date,
        [NotNullWhen(true)] out string? solstice
    )
    {
        if (date.Day is 21)
        {
            if ((solstice = date.Month switch
            {
                12 => "Winter Solstice",
                6 => "Summer Solstice",
                _ => null
            }) is not nullreturn true;
        }
        solstice = null;
        return false;
    }
}

Constants can be any literal including strings, defined constants, and, of course, null. Note that the comparison of a string to the literal empty string "" will work, but comparing it to string.Empty will fail because string.Empty is not declared as a constant.

Relational Patterns (C# 9.0)

With relational patterns you can compare to a constant relatively using the operators <, >, <=, and >=. Listing 7.24 provides both is operator and switch expression versions of relational patterns. (Note that == is not one of the operators since this functionality is already available in the constant pattern matching.)

Listing 7.24: Relational Pattern Matching Examples
public static bool IsDeveloperWorkHours(
    int hourOfTheDay) =>
        hourOfTheDay is > 10 &&
            hourOfTheDay is < 24;
 
 
public static string GetPeriodOfDay(int hourOfTheDay) =>
    hourOfTheDay switch
    {
        < 6 => "Dawn",
        < 12 => "Morning",
        < 18 => "Afternoon",
        < 24 => "Evening",
        int hour => throw new ArgumentOutOfRangeException(nameof(hourOfTheDay), 
            $"The hour of the day specified is invalid.")
    };

It is important to note that starting in C# 7.0, match expressions are no longer necessarily mutually exclusive. For example, we could have a match expression of > 0 and another with < 24 and both would be true regardless of the hour. Order will make the final determination of which path the code execution follows. For this reason, use caution when ordering the match expressions in switch statements and expressions (in the same way you would be careful about ordering a series of if statements).

Logical Patterns (C# 9.0)

With logical patterns we can combine relational match expressions with a single variable and the operators not, and, and or, providing negated, conjunctive, and disjunctive patterns respectively. Therefore, rather than using the logical operators like && or || (see the IsDeveloperWorkHours() method in Listing 7.24 for example) you can specify the input operand once and then have multiple match expressions as shown in Listing 7.25.

Listing 7.25: Logical Pattern Matching Examples
public static bool IsStandardWorkHours(
    TimeOnly time) =>
        time.Hour is > 8
            and < 17
            and not 12; // lunch
 
public static bool TryGetPhoneButton(
    char character,
    [NotNullWhen(true)] out char? button)
{
    return (button = char.ToLower(character) switch
    {
        '1' => '1',
        '2' or >= 'a' and <= 'c' => '2',
        // not operator and parenthesis example (C# 10)
        '3' or not (< 'd' or > 'f') => '3',
        '4' or >= 'g' and <= 'i' => '4',
        '5' or >= 'j' and <= 'l' => '5',
        '6' or >= 'm' and <= 'o' => '6',
        '7' or >= 'p' and <= 's' => '7',
        '8' or >= 't' and <= 'v' => '8',
        '9' or >= 'w' and <= 'z' => '9',
        '0' or '+' => '0',
        _ => null,// Set the button to indicate an invalid value
    }) is not null;
}

The order of precedence is not, and, or; thus the first two examples don’t need parentheses. Parentheses, however, are allowed to change the default order of precedence as demonstrated (arbitrarily) by match expressions in the switch expression of Listing 7.24.

Be aware that when using the or and not operators, you cannot also declare a variable. For instance:

if (input is "data" or string text) { }

will result in an error: “CS8780: A variable may not be declared within a ‘not’ or ‘or’ pattern.” Doing so would result in ambiguity about whether initialization of text occurred.

Parenthesized Patterns (C# 9.0)

To override the order of precedence of logical pattern operators you can group them together using parentheses (see Listing 7.26).

Listing 7.26: Parenthesized Patterns
public bool IsOutsideOfStandardWorkHours(
    TimeOnly time) =>
        time.Hour is not
            (> 8 and < 17 and not 12); // Parenthesis Pattern - C# 10.

Parenthesizing is available with both is operators and switch syntax.

Tuple Patterns (C# 8.0)

With tuple pattern matching, you can check for constant values within the tuple or assign tuple items to a variable (see Listing 7.27).

Listing 7.27: Tuple Pattern Matching with the is Operator
public static void Main(params string[] args)
{
    const int command = 0;
    const int fileName = 1;
    const string dataFile = "data.dat";
 
    // ...
    if ((args.Length, args[command].ToLower()) is (1, "cat"))
    {
        Console.WriteLine(File.ReadAllText(dataFile));
    }
    else if ((args.Length, args[command].ToLower())
        is (2, "encrypt"))
    {
        string data = File.ReadAllText(dataFile);
        File.WriteAllText(
            args[fileName], Encrypt(data).ToString());
    }
}

As expected, the same is possible with the switch expression or statement syntax (see Listing 7.28)

Listing 7.28: Tuple Pattern Matching with the switch Statement
public static void Main(params string[] args)
{
    const int action = 0;
    const int fileName = 1;
    const string dataFile = "data.dat";
 
    // ...
    switch ((args.Length, args[action].ToLower()))
    {
        case (1, "cat"):
            Console.WriteLine(File.ReadAllText(dataFile));
            break;
        case (2, "encrypt"):
            {
                string data = File.ReadAllText(dataFile);
                File.WriteAllText(
                    args[fileName], Encrypt(data).ToString());
            }
            break;
        default:
            Console.WriteLine("Arguments are invalid.");
            break;
    }
}

In both Listing 7.27 and Listing 7.28, we pattern match against a tuple that is populated with the length and the elements of args. In the first match expression, we check for one argument and the action "cat". In the second match expression, we evaluate whether the first item in the array is equal to "encrypt". In addition, Listing 7.27 reads the filename from the second item in args if the match expression evaluates to true. The switch statement in Listing 7.28 doesn’t make the variable assignment since the input operand of the switch statement is the same for all match expressions.

Each element match can be a constant or a variable. Since the tuple is instantiated before the is operator executes, we can’t use the "encrypt" scenario first because args[FileName] would not be a valid index if the "cat" action was requested.

Positional Patterns (C# 8.0)

Building on the deconstructor construct introduced in C# 7.0 (see Chapter 6), C# 8.0 enables positional pattern matching with a syntax that closely matches tuple pattern matching (see Listing 7.29).

Listing 7.29: Positional Pattern Matching with the is Operator
using System.Drawing;
 
public static class PointHelper
{
    public static void Deconstruct(
        this Point point, out int x, out int y) =>
            (x, y) = (point.X, point.Y);
 
    public static bool IsVisibleOnVGAScreen(Point point) =>
        point is (>=0 and <=1920, >=0 and <=1080);
 
    public static string GetQuadrant(Point point) => point switch
    {
        (>=0, >=0) => "Quadrant I",   //  II | I
        (<=0, >=0) => "Quadrant II",  // ____|____
        (<=0, <=0) => "Quadrant III"//     |
        (>=0, <=0) => "Quadrant IV"   // III | IV
    };
}

The System.Drawing.Point type doesn’t have a deconstructor. However, we are able to add one as an extension method, which satisfies the criteria for converting the Point to a tuple of X and Y. The deconstructor is then used to match the order of each comma-separated match expression in the pattern. In the example of IsVisibleOnVGAScreen(), X is matched with >=0 and <=1920 and Y with >=0 and <=1080. Similar range expressions are used with the switch expression in GetQuadrant().

Property Patterns (C# 8.0 & 10.0)

With property patterns, the match expression is based on property names and values of the data type identified in the switch expression, as shown in Listing 7.30.

Listing 7.30: Property Pattern Matching with the is Operator
public record class Employee
{
    public int Id { getset; }
    public string Name { getset; }
    public string Role { getset; }
 
    public Employee(int id, string name, string role) =>
        (Id, Name, Role) = (id, name, role);
}
 
 
public class ExpenseItem
{
    public int Id { getset; }
    public string ItemName { getset; }
    public decimal CostAmount { getset; }
    public DateTime ExpenseDate { getset; }
    public Employee Employee { getset; }
 
    public ExpenseItem(
        int id, string name, decimal amount, DateTime date, 
            Employee employee) =>
                (Id, Employee, ItemName, CostAmount, ExpenseDate) = 
                (id, employee, name, amount, date);
 
    public static bool ValidateExpenseItem(ExpenseItem expenseItem) =>
        expenseItem switch
        {
            // Note: A property pattern checks that the input value is not null
            // Expanded Property Pattern
            { ItemName.Length: > 0, Employee.Role: "Admin" } => true,
            #pragma warning disable IDE0170 // Property pattern can be simplified
            // Property Pattern
            { ItemName: { Length: > 0 }, Employee: {Role: "Manager" }, 
                ExpenseDate: DateTime date } 
            #pragma warning restore IDE0170 // Property pattern can be simplified
                when date >= DateTime.Now.AddDays(-30) => true,
            { ItemName.Length: > 0,  Employee.Name.Length: > 0, 
                CostAmount: <= 1000, ExpenseDate: DateTime date }
                when date >= DateTime.Now.AddDays(-30) => true,
            { } => false// This not null check can be eliminated.
            _ => false
        };
}

The first important thing to note about all property match expressions is that they will match only when the input operand is not null. This is why, for example, a match expression of { }, an empty property match expression, matches whenever the input operand is not null. (In Listing 7.30, the { } match expression is redundant as the default match expression at the end of the switch will catch both null and remaining non-null input operands that fall through anyway.) While { } works as a not null check, simply specifying not null (C# 9.0) is preferred as the syntax is clearer.

In Listing 7.30, the first match expressions within the switch expression starts with ItemName.Length: > 0, requiring that ItemName.Length be greater than 0. Implied in the expression is that ItemName is also not null. In fact, since the initial null check is implicit, the compiler disallows using the null conditional in the property expression (ItemName?.Length).

In Listing 7.30, the first match expression also requires that Employee.Role is set to "Admin". And, if these first two match expressions match, the switch expression returns true, indicating ExpenseItem is valid. The complete match expression uses an expanded property pattern match syntax introduced in C# 10 and identified by the dot notation that accesses subproperties (Length and Role).

The second match expression for Listing 7.30, { ItemName: { Length: > 0 }, Employee: {Role: "Manager" }, ExpenseDate: DateTime date }, is also a property match expression (rather than an expanded property match expression) but with a slightly different syntax. The property match expression was introduced in C# 7.0 and is still supported, albeit with a warning, because the expanded syntax is simpler and preferred in all cases.

This second match expression includes a property match for ExpenseDate for which it declares a variable, date. While date is still used as part of the switch filter, it appears in a when clause rather than the match expression.

When Clause

For all forms of constant and relational match expressions, it is necessary to use a constant. This, however, can be restrictive given that frequently the comparative operand is not known at compiler time. To avoid the restriction, C# 7.0 includes a when clause into which you can add any conditional expression (an expression that returns a Boolean).

The code in Listing 7.30 declares a DateTime date variable to check that the ExpenseItem.ExpenseDate is no older than 30 days. However, because age is dependent on the current date and time, the value isn’t constant, and we can’t use a match expression. Instead, the code uses a when clause following the match expression where the date value is compared to DateTime.Now.AddDays(-30). The when clause provides a catchall location for (optionally) more complex conditionals without the restriction of only using constants.

The addition of when clauses is the core reason why C# 7.0 allowed match expressions to no longer be mutually exclusive. With a when clause, it is no longer possible to determine at compile time to evaluate the full case logic and check for exclusivity. As a reminder, be careful of the order that each case appears within a switch expression to avoid having earlier cases catch conditions meant for later cases.

Pattern Matching with Unrelated Types

An interesting capability of pattern matching is that it becomes possible to extract data from unrelated types and morph the data into a common format. Listing 7.31 provides an example.

Listing 7.31: Pattern Matching within a switch Expression
public static string? CompositeFormatDate(
        object input, string compositeFormatString) =>
    input switch
    {
        DateTime 
            { Year: int year, Month: int month, Day: int day }
            => (year, month, day),
        DateTimeOffset
            { Year: int year, Month: int month, Day: int day }
                => (year, month, day),
        DateOnly 
            { Year: int year, Month: int month, Day: int day }
                => (year, month, day),
        string dateText => DateTime.TryParse(
            dateText, out DateTime dateTime) ?
                (dateTime.Year, dateTime.Month, dateTime.Day) :
                // default ((int Year, int Month, int Day)?)
                // preferable but not covered until Chapter 12.
                ((int Year, int Month, int Day)?) null,
        _ => null
    } is (intintint) date ? string.Format(
        compositeFormatString, date.Year, date.Month, date.Day) : null;

The first match expression of the switch expression in Listing 7.32 uses type pattern matching (C# 7.0) to check whether the input is of type DateTime. If the result is true, it passes the result to the property pattern matching to declare and assign the values year, month, and day; it then uses those variables in a tuple expression that returns the tuple (year, month, day). The DateTimeOffset and DateOnly match expressions work the same way.

Given a match on the string match expression, if TryParse() is unsuccessful, we return a default((int Year, int Month, int Day)?),2 which evaluates to null. It is not possible to simply return null because there is no implicit conversion from (int Year, int Month, int Day) (the ValueTuple type returned by the other match expressions) and null. Rather, a nullable tuple data type needs to be specified to accurately determine the switch expression’s type. (The alternative to using the default operator would be to cast ((int Year, int Month, int Day)?) null..

In Listing 7.31, we have four data types that, while conceptually similar, have no inheritance relationship between them other than their derivation from object. As such, the only data type available for the input operand is object. Yet, from each we can extract properties for year, month, and day (even if the names don’t exist or they vary between the data types). In this case, we extract (int Year, int Month, int Day)?, a nullable tuple. And, as long as the tuple is not null, we are able to use the extracted value for year, month, and day into the compositeFormatString value.

Recursive Pattern Matching (C# 7.0)

As mentioned earlier, much of the power of pattern matching doesn’t really emerge until it is leveraged within a switch statement or expression. The one exception, however, might be when pattern matching is used recursively. Listing 7.32 provides an (admittedly contrived) example of the potential complexity when applying patterns recursively.

Listing 7.32: Recursive Pattern Matching with the is Operator
// ...
Person inigo = new("Inigo""Montoya");
var buttercup = 
    (FirstName: "Princess", LastName: "Buttercup");
 
(Person inigo, (string FirstName, string LastName) buttercup) couple =
    (inigo, buttercup);
 
if (couple is 
    ( // Tuple: Retrieved from deconstructor of Person
        ( // Positional: Select left side or tuple
            { // Property of firstName
                Length: int inigoFirstNameLength
            }, 
         _ // Discard last name portion of tuple
        ),
    { // Property of Princess Buttercup tuple
        FirstName: string buttercupFirstName }))
{
    Console.WriteLine(
        $"({ inigoFirstNameLength }{ buttercupFirstName })");
}
else
{
    // ...
}
// ...

In this example, couple is of the following type:

(Person, (string FirstName, string LastName))

As such, the first match occurs on the outer tuple, (inigo, buttercup). Next, positional pattern matching is used against inigo, leveraging the Person deconstructor. This selects a (FirstName, LastName) tuple, from which property pattern matching is used to extract the Length of the inigo.FirstName value. The LastName portion of the positional pattern matching is discarded with an underscore. Finally, property pattern matching is used to select buttercup.FirstName.

While pattern matching is a powerful means to select data, it is not without a caution: Be mindful of readability. Even with the comments of Listing 7.32, it is likely challenging to understand the code. Without them, it would be even harder:

if (couple is ( ( { Length: int inigoFirstNameLength }, _ ),

    { FirstName: string buttercupFirstName })) { ...}

Even so, where pattern matching really proves useful is in switch statements and expressions.

List Patterns (C# 11.0)

One important new feature in C# 11 was the introduction of list patterns. These enable pattern matching over a list of items such as an array. As such, you can use any type of pattern matching on the elements within the list. Listing 7.33 demonstrates the feature to parse the args parameter of the Main method.

Listing 7.33: Pattern Matching within a switch Expression
public static void Main(string[] args)
{
    // For simplicity, options are assumed
    // to all be lower case.
 
    // The first argument is the option and is
    // identified by a '/', '-', or '--' prefix.
 
    switch (args)
    {
        case ["--help" or ['/' or '-', 'h' or '?']]:
            // e.g. --help, /h, -h, /?, -? 
            DisplayHelp();
            break;
        case [ ['/' or '-', char option], ..]:
            // Option begins with '/', '-' and has 0 or more arguments.
            if(!EvaluateOption($"{option}"args[1..]))
            {
                DisplayHelp();
            }
            break;
        case [ ['-', '-', ..] option, ..]:
            // Option begins with "--" and has 0 or more arguments.
            if(!EvaluateOption(option[2..], args[1..]))
            {
                DisplayHelp();
            }
            break;
 
        // The following cases are redundant with default
        // but provided for demonstration purposes.
        case []:
            // No command line arguments
 
        default:
            DisplayHelp();
            break;
    }
}
 
private static bool EvaluateOption(string option, string[] args) =>
    (option, argsswitch
    {
        ("cat" or "c", [string fileName]) =>
            CatalogFile(fileName),
        ("copy", [string sourceFile, string targetFile]) =>
            CopyFile(sourceFile, targetFile),
        _ => false
    };
 
private static bool CopyFile(object sourceFile, string targetFile)
{
    Console.WriteLine($"Copy '{sourceFile}' '{targetFile}'...");
    return true;
}

The first case in Listing 7.33 looks for the text "—help" as the first and only element of the args array. If there is no match, then the remainder of the list match expression matches on a first element that starts with / or - followed by ? or h. The latter part of the list pattern looks for exactly two characters (because a string is an array of characters) as identified by the comma (,) within the expression, separating the prefix from the single character expression:

['/' or '-', 'h' or '?']

The next two cases evaluate the first element of args as well, determining whether the first character is a / or a - or, in the third case, whether the first element of args starts with --. In both cases, if there is a match, args[0] is assigned to the option variable. Unfortunately, there is no way to capture the remaining elements of the list into a variable. List pattern matching always looks for an exact number of elements or allows for .. to mean no further evaluation of the remaining elements is required. For this reason, when invoking EvaluateOptions(), we use the range args[1..] to pass all the elements in args except the first one (see Chapter 3 for examples on ranges).

The fourth case uses [ ] to identify a list with 0 elements. In this example, it is redundant because the default case would also capture the case when args has zero elements.

Within the EvaluateOptions() method of Listing 7.33, we use a tuple to first evaluate the command portion. The second element in the tuple evaluates the remaining arguments. For the "cat" match expression, it looks for a list with one item, whereas for the "copy" match expression, it looks for a list of exactly two elements. In both cases, it captures the array elements into variables and uses them to invoke the corresponding command method.

There is a lot of power in the list pattern matching capability, and because it works with the characters of strings, it enables a host of simple scenarios for string parsing without resorting to regular expressions.

________________________________________

2. See Chapter 12 for more information.
{{ snackbarMessage }}
;