Pattern Matching, Switch Expression and More

Pattern Matching, Switch Expression and More

Writing concise code.

ยท

17 min read

One of the major feature of C# version 7.0 was pattern matching. Pattern matching is a way to recognize structure or pattern within some context. Think of pattern matching like a simple puzzle where you examine the pieces and look for matching edges or specific shapes that fit together. Similarly, in C# pattern matching is looking at the data or context and look for patterns that match the criteria.

Introduction

Categorizing and processing data based on some context is called pattern matching. By context I mean specific characteristics, properties, or structure of the data that we are analyzing. This context provides the criteria or patterns against which we match and classify the data, allowing us to make informed decisions and take appropriate actions. Pattern matching can help make codebase look much more concise. In this article, I will introduced you to various types of pattern matching.

Basic Patterns

Basic pattern are simple building blocks of pattern matching which is used to compare an expression's value against simple criteria.

Here are some basic pattern:

Constant pattern

It allows you to match a specific constant value against the value being evaluated. Constant pattern makes conditional logic concise and introduce better readability.

Eg: Using is operator.

string message = "Welcome back!";
if(message is null){
   Console.WriteLine("Message is null");
}
// Output: Since, condition is not fullfiled, it won't print message.

You can use is operator to check for specific match. C# version 9.0 introduced not operator. Now, you check of specific match like:

string message = "Welcome back!";
if (message is not null){
   Console.Writeline($"Message is not null. {message}");
}
// Output: Message is not null. Welcome back!

Constant patterns can be used with various other expressions like variables, literals, properties, method calls, and more.

Relational Pattern

Similar to constant pattern which checks for a constant specific value, relational pattern checks for a constant specific value and apply relational operators. Imaging if you want to write a logic which checks if the message is not null and message's length is greater than 20.

string message = "Welcome back!"; // length = 13
if(message is not null && message.Length is not < 20)
    Console.WriteLine(message);
else Console.WriteLine("Welcome back to FC 24!");
// output: Welcome back to FC 24!

Relational pattern is same as constant pattern but you can use relational operators.

Type Pattern

As the name says, type pattern checks if the value is of some specific type. Imagine a scenario where you have an object which may be of type string or int. If it is string, its a name else its an age. You can perform this conditional logic by:

void isNameOrAge(object data)
{
    if (data is string name)
        Console.WriteLine($"{name} is a name.");
    else if (data is int age)
        Console.WriteLine($"{age} ia an age.");
    else Console.WriteLine("Invalid");
}
isNameOrAge("ram bahadur"); // prints: ram bahadur is a name.
isNameOrAge(21); // prints: 21 ia an age.

You can read this: if (data is string name) ... like if data's type is string, assign that value to name, else if (data is int age) ... else if it is int, assign that value to age.

Declaration Pattern

This is similar to type pattern but declares a new value if an condition is fulfilled. Imagine a scenario where you need to write a logic to check if a person's age is greater than 21. But age can be of type string or int.

void isGreaterThan21(object age)
{
    if (age is int ageIntValue || (age is string ageStringValue && int.TryParse(ageStringValue, out ageIntValue) && ageIntValue is < 21))
        Console.WriteLine($"Age is {ageIntValue}. You cannot play this game."); 
    else
        Console.WriteLine($"You can play this game.");
}
isGreaterThan21("42");
isGreaterThan21(15);
/*
Output: 
       You can play this game.
       Age is 15. You cannot play this game.
*/

Let me break down this code: if (age is int ageIntValue || (age is string ageStringValue && int.TryParse(ageStringValue, out ageIntValue) && ageIntValue is < 21))

In my first or condition, I checked if age is int. If it is int then, create a new variable of type int called ageIntValue and assign object age's value to it. It won't check for another condition. After that it will move on to another line which prints out some message.

If my first or condition fails, it will check second condition. In second condition, I've checked if it is string. If it is an string, then create a new variable of type string called ageStringValue and assign object age value to it. After that I have an and operator which parse ageStringValue into int and assign it to ageIntValue. Finally, another and operator which checks if ageIntValue is greater than 21 (using relational pattern).

Switch Expression

Switch expression were introduced in C# version 8.0 to write more concise switch "statement". Instead of chaining if() else(), switch statement is much more versatile and concise.

Switch expression is a evolution of switch statements with added benefit of chaining pattern matching.

Here is simple switch statement which prints about fruit:

public void GetFruitInfo_SwitchStatement(string fruitName)
{
    switch (fruitName.ToLower())
    {
        case "apple":
            Console.WriteLine("An apple a day keeps the doctor away!");
            break;
        case "banana":
            Console.WriteLine("Banana is good for potassium.");
            break;
        case "orange":
            Console.WriteLine("Oranges are a great source of Vitamin C.");
            break;
        case "mango":
            Console.WriteLine("Mangoes are delicious and refreshing.");
            break;
        case "grapes":
            Console.WriteLine("Grapes come in many varieties, red, green, and purple!");
            break;
        case "strawberry":
            Console.WriteLine("Strawberries are perfect for adding a sweet touch to desserts.");
            break;
        default:
            Console.WriteLine("Not a fruit.");
            break;
    }
}
// calling code
GetFruitInfo_SwitchStatement("grapes");
/*
Output: Grapes come in many varieties, red, green, and purple!
*/

Now, I will change the same switch statement to switch expression:. Before that I would like to let you know that void is not supported for switch expression. You can return Action for similar (void) result.

public string GetFruitInfo_SwitchExpression(string fruitName) => fruitName.ToLower() switch
{
    "apple" => "An apple a day keeps the doctor away!",
    "banana" => "Banana is good for potassium.",
    "orange" => "Oranges are a great source of Vitamin C.",
    "mango" => "Mangoes are delicious and refreshing.",
    "grapes" => "Grapes come in many varieties, red, green, and purple!",
    "strawberry" => "Strawberries are perfect for adding a sweet touch to desserts.",
    _ => "Not a fruit."
};
// calling code
Console.WriteLine(GetFruitInfo_SwitchExpression("strawberry")); 
/*
Output: Strawberries are perfect for adding a sweet touch to desserts.
*/

This is much more concise. But conciseness isn't the only advantage of switch expressions. It's the combination of switch expressions with pattern matching that makes them truly versatile and powerful. To demonstrate this, lets modify our parameter fruitName from string to an object type. I will create an immutable data which consist detail of the fruit.

public record Fruit(string name, decimal cost, string producedCountry);
public string GetFruitInfo_SwitchExpression(Fruit fruit) => fruit switch
{
    { name: "apple", costInUSD: < 0.5m, producedIn: "Nepal"} => "An apple from Nepal which is cost efficient.",
    { name: "apple", costInUSD: > 2, producedIn: not "Nepal"} => "An apple which is not from Nepal and is expensive.",
    { name: "apple", costInUSD: < 1,  producedIn: _ } => "A juicy apple.",
    { name: "apple", costInUSD: _ , producedIn: _ } => "An apple.",
    _ => "Not an apple."
};
// calling code
Fruit fruit = new Fruit(name: "apple", costInUSD: 0.75m, producedIn: "Nepal");
Fruit fruit1 = new Fruit(name: "apple", costInUSD: 4.5m, null);

Console.WriteLine(GetFruitInfo_SwitchExpression(fruit)); 
Console.WriteLine(GetFruitInfo_SwitchExpression(fruit1)); 
/*
Output:  A juicy apple.
         An apple which is not from Nepal and is expensive.
*/

This switch expression leverages pattern matching (Constant Pattern using not, and Relational Pattern using < > ) to compare the properties of the fruit object against different cases. Each case is enclosed in curly braces {}.

Each case in the expression doesn't explicitly use a return statement. This is because of lambda. You can find more about lambda and closed variables here. Basically result of this lambda function is the return type/value of GetFruitInfo_SwitchExpression() function. _ is known as discard. I will explain this next, for now think this as default of switch statement.

Discard Pattern

Discard is denoted by _ (underscore). Discard pattern is used when you don't want a value or you don't care about something. Discard pattern automatically infer type, meaning _'s type will be automatically inferred based on the context(value assigned to it). Discarded value will also not be stored in a allocated memory, but stored in a temporary variable which will be collected by garbage collector.

Examples:

string someVal = "44";
if (int.TryParse(someVal, out _ )) // discard pattern
    Console.WriteLine("someVal is a int.");
// output: someVal is a int.

public string GetFruitInfo_SwitchExpression(Fruit fruit) => fruit switch
{
    { name: "apple", costInUSD: < 0.5m, producedIn: "Nepal"} => "An apple from Nepal which is cost efficient.",
    { name: "apple", costInUSD: > 2, producedIn: not "Nepal"} => "An apple which is not from Nepal and is expensive.",
    _ => "Not an apple." // any Fruit except apple will reach here.
};
GetFruitInfo_SwitchExpression("orange");
// output: Not an apple.

Deconstruction Patterns

Deconstruction pattern are simply deconstructing an object(extracting information) and applying your conditional logic. Before moving on to some deconstruction pattern, let's look at deconstruct in C#.

Deconstruct

Deconstructing was introduced in C# version 7.0. Main motive of deconstruct is to pull out data from an user defined type.

Deconstructing is done by adding a method of type void with name Deconstruct. This method's parameter are those which you want the user of this object to pull out. Deconstruct method takes only out parameter. Why? Because Deconstruct method needs to return back value to the caller which is not done by return statement but out parameter. Why? Deconstruct method extract values outside the method. So when you pass a parameter to Deconstruct, the method assigns the extracted values directly to those parameter.

Here's a simple example:

public struct LaptopRecommendation(decimal _budget, string _brand)
{
    public decimal budget = _budget;
    public string brand = _brand;

    public void Deconstruct(out decimal LaptopBudget, out string LaptopBrand) => (LaptopBudget, LaptopBrand) = (budget, brand);
}

Now, when any caller use LaptopRecommendation object, they can leverage Deconstruct method. Basically the caller can easily unpack LaptopRecommendation object's property (budget and brand) into a separate variables so, the caller can efficiently interact with the LaptopRecommendation object's data.

For example, lets say I have a list of LaptopRecommendation objects. I want to store any LaptopRecommendation object whose brand is Acer and laptop whose cost is less than 1300.

// list of LaptopRecommendation object
var recommendations = new List<LaptopRecommendation>();
recommendations.Add(new LaptopRecommendation(1200, "acer"));
recommendations.Add(new LaptopRecommendation(600, "dell"));
// list of laptop whose brand is acer and cost is less than 1300
var acerRecommendations = recommendations
                        .Where(recommendation =>
                        {
                            (decimal budget, string brand) = recommendation;
                            return brand is "acer" && budget < 1300;
                        })
                        .ToList();
Console.WriteLine(string.Join(", ", acerRecommendations.Select(x => x.brand)));
// output: acer

In acerRecommendations's where filter, I've create two variables budget and brand of type decimal and string respectively and assigned them to recommendation (single object of List<LaptopRecommendation>). recommendation deconstructs and assign value in these newly created variables. LaptopRecommendation's Deconstruct method contains two out params, they will be assigned to our two newly created variables(budget and brand).

Here are some deconstruction pattern:

Property pattern

Property pattern is just a trick to deconstruct an object and match specific property within the object. What I mean by that is, it allows you to check for a specific condition within the property or field within that object. Eg: Check if an Person object's property Age is greater than 18. Here, we deconstruct Person object (extracting properties' values) and check the property Age if its greater than 18.

Syntax: expression is {property1: someValue1, property2: someValue2, ...}

Eg: Using property pattern

// Person object.
public class Person(int _age, string _name, DateTime _dob)
{
    public int Age { get; set; } = _age;
    public string Name { get; set; } = _name;
    public DateTime DateofBirth { get; set; } = _dob;

    public override string ToString()
    {
        return $"Name: {Name}, Age: {Age}, Date of birth: {DateofBirth}";
    }

    public void Deconstruct(out int personAge, out string personName, out DateTime personDateofBirth) => (personAge, personName, personDateofBirth) = (Age, Name, DateofBirth);
}
// initialize person object
var person1 = new Person(17, "ram bahadur", DateTime.Parse("2004-01-01"));
Console.WriteLine(person1.ToString()); // prints person object info
if(person1 is { Age: < 21 })
    Console.WriteLine($"{person1.Name} is not eligible to drink beer. Try milk.");
else Console.WriteLine("Here is your beer. Drink responsibly.");
/* 
Output: 
Name: ram bahadur, Age: 17, Date of birth: 1/1/2004 12:00:00 AM
ram bahadur is not eligible to drink beer. Try milk.
*/

Using property pattern when working with DateTime is very useful. Imagine a scenario where you need to check if date is between some particular time. Eg: I would like to know if a person is born in March.

bool isBornInMarch(DateTime dob)
{
    if (dob is { Month: 3 })
        return true;
    else return false;
}
Console.WriteLine($"{person1.Name} is born is march? {isBornInMarch(person1.DateofBirth)}."); 
/*
Output: ram bahadur is born is march? False.
*/

Similarly, you can deconstruct an object and apply your conditional logic using property matching.

Positional Pattern

Similar to property pattern which checks deconstruct an object and applies condition to property, positional pattern deconstruct object and checks for defined condition.

Imagine a scenario where you need to write a function which takes budget and brand as a parameter and return appropriate laptop.

Firstly, I will create an data structure to hold both of these value. This can be tuple as well but in this example I will create an struct to hold values. Here, object is of struct type but you can create of reference type as well.

public struct LaptopRecommendation(decimal _budget, string _brand)
{
    public decimal budget = _budget;
    public string brand = _brand;

    public void Deconstruct(out decimal LaptopBudget, out string LaptopBrand) => (LaptopBudget, LaptopBrand) = (budget, brand);
}

Main function to check the conditional logic:

string getLaptopDetail(LaptopRecommendation info) => info switch
{
    ( <= 1000 and > 500, "acer") => "You should get a Acer Aspire 5.",
    ( <= 1400 and > 1000, "acer") => "You should get a Acer Nitro.",
    ( <= 1500 and > 1000, "acer") => "You should get a Acer Predator.",
    ( <= 1000 and > 500, "dell") => "You should get a Dell Inspiron.",
    ( <= 1400 and > 1000, "dell") => "You should get a Dell G series PC.",
    ( <= 1500 and > 1000, "dell") => "You should get a Dell Alienware.",
    ( >= 3000, object) => "You should either get a Mac Pro M2 Series or a beefy razer series laptop. (I would get a beefy razer laptop)",
    _ => "I recommend you get any laptop from Lenovo Legion series.",
};

More

Before looking at some special pattern like List Pattern, I would like to make sure you understand ternary and null coalescing operator.

Null coalescing

Null coalescing was introduced in early version of C# (2.0). Null coalescing or ?? operator, is a way to handle null values. ?? is a binary operator, meaning it operates in two operands (left and right).

string someData = null;
string someDataBak = "N/A";
string returnData = someData ?? someDataBak;
Console.WriteLine(returnData);

You can read someData ?? someDataBak; like, if someData not null, use it else use someDataBak.

Ternary Operator

Conditional operator or ternary operators is a way to perform conditional operation in a concise way. Syntax: condition ? expression1 : expression2 or you can read this as: if condition is true, compute expression1 else expression2.

Simple example to check user's age to vote using ternary:

int age = 17;
string consoleMessage = age > 18 ? "Eliglble to vote." : "Not eligible to vote";
Console.WriteLine(consoleMessage); 
/*
Output: Not eligible to vote
*/

Special Pattern

Var pattern

Var pattern was introduced in C# version 7.1. As the name say "var", var pattern is used when you want something to be stored in var and utilize it for some expression. Var pattern is very similar to type pattern. Instead of explicitly specifying type, you use var.

Example: Imagine you have a list of random objects. You want to know if this random list contains more than two animals.

var listOfAnimal = new List<string>() { "dog", "cat", "rhino"};
var listOfRandomItems = new List<object>() { "Ram", "hari", "John", 421, "bijay", "macafee", "apple", "dog", "cat" };
bool containsAnimal(List<object> randomStuff)
{
    return randomStuff.Intersect(listOfAnimal) is var animalCount && animalCount.Count() > 2;
}
Console.WriteLine($"listOfRandomItems contains animal? : {containsAnimal(listOfRandomItems)}");
/*
Output: listOfRandomItems contains animal? : False
*/

Instead of explicitly defining type like List<object> or IEnumerable<object>, you can simply use var pattern.

List pattern

Introduced in C# version 11.0, list pattern is a very concise way to condition a list. Meaning list pattern helps you to filter and match certain condition in a list without having to write lengthy code.

For example, I have a list and I would like to check if the first value of that list is Blueskie, check third value name is Kumar, and check if last value of that list is Bret.

First case: Check if the first value is "Blueskie"

var simpleListOfName = new List<string> { "Blueskie", "Jon", "Jonsky", "Angel", "Bret" };
if(simpleListOfName is ["Blueskie", _, _, _, _])
    Console.WriteLine("First name is Blueskie.");
/*
Output: First name is Blueskie.
*/

Here in the first case, I only care about 1st value of the list. So I discarded other values (_).

Second case: Check if the third value is "Kumar"

if(simpleListOfName is [_, _, "Kumar", _, _])
    Console.WriteLine("Third name is Kumar.");
else Console.WriteLine("Third name is not kumar.");
/*
Ouput: Third name is not kumar.
*/

Here in the second case, I only care about 3rd value of the list. So I discarded other values (_).

Third case: Check if the last value is "Bret"

if (simpleListOfName is [.., "Bret"])
    Console.WriteLine("Last name is Bret.");

Here in the third case, I only care about last value of the list. I don't really care about values till last. So, I can use slice sub-pattern ( .. or two dots ). Slice pattern can be used only in list pattern. Also, C# compiler prohibits using more than one slice pattern.

Another example using list pattern with switch expression. Imagine a scenario where you want print first and fourth value from a list.

var listOfNumbers = new List<int> {765, 2, 34, 5, 2, 3, 33, 66 };
var firstAndFourth = listOfNumbers is [int firstVal, _, _, int fourthVal, ..] ? $"First num is:{firstVal}, fourth num is: {fourthVal}" : "N/A";
Console.WriteLine(firstAndFourth);
/*
Output: First num is:765, fourth num is: 5
*/

Another example, print second and last value from a list using switch expression.

var listOfNumbers = new List<int> { 765, 2, 34, 5, 2, 3, 33, 66 };
var numResult = listOfNumbers switch
{
    [_, int second, ..] => $"Second num is: {second}",
    [.., int last] => $"Last num is: {last}",
    _ => "I dont care",
};
Console.WriteLine(numResult);
/*
Ouput: Second num is: 2
*/

In switch expression using list pattern when condition is matched, only that matched condition is executed. Therefore, above example only prints Second num is: 2.

Another example of list pattern is using it in CSV file. I have a CSV data of employees.

dawg@example.com,1,Tom,Thomson,founder
laura@example.com,4,Laura,Grey,manager
craig@example.com,8,Craig,Johnson,deputymanager
mary@example.com,77,Mary,Jenkins,engineer
jamie@example.com,101,Jamie,Smith,intern
mikey@gmail.com,00,Mike,Paul,clown

I would like to provide some information based on the employee. Like, greet them if they are manager, and let them know how early they joined the company (based on their id which is sequential).

using var emp = new StreamReader("../../../email.csv");
while (!emp.EndOfStream)
{
    string[]? empInfo = emp.ReadLine()?.Split(',');
    var greetAccordingly = empInfo switch
    {
        [_, _, _, string lastName, "manager"] => $"Good Evening, Manager {lastName}.",
        [_, string id, _, ..] => int.TryParse(id, out int empId) && (empId > 2 && empId < 10) ? "You are top 10 employee to join da Company." : "You joined the company after 10 people joined.",
        [_, string id, ..] => int.TryParse(id, out int empId) && empId is 1 ? "Hello Mr CEO." : "Well, Hello there :-) ",
        _ => "Nothing matched."
    };
    Console.WriteLine(greetAccordingly);
}
/*
Output: 
        You joined the company after 10 people joined.
        Good Evening, Manager Grey.
        You are top 10 employee to join da Company.
        You joined the company after 10 people joined.
        You joined the company after 10 people joined.
        You joined the company after 10 people joined.
*/

Another example, check employee email for verification (on same above CSV file). Basically, email with the extension @example.com is verified.

using var emp = new StreamReader("../../../email.csv");
while (!emp.EndOfStream)
{
    string[]? empInfo = emp.ReadLine()?.Split(',');
    var checkVerifiedEmployee = empInfo switch
    {
        [string email, ..] => email.Split("@").Contains("example.com") ? $"{email} is Verified Example worker." : $"{email} is Unverified user.",
        _ => "Nothing matched."
    };
    Console.WriteLine(checkVerifiedEmployee);
}
/* Output:
           dawg@example.com is Verified Example worker.
           laura@example.com is Verified Example worker.
           craig@example.com is Verified Example worker.
           mary@example.com is Verified Example worker.
           jamie@example.com is Verified Example worker.
           mikey@gmail.com is Unverified user.
*/

You can learn more about List pattern :

  1. Microsoft docs

  2. endjin's blog

Summary

  • Pattern matching offers a versatile way to check data against various condition, including types, values, relational operators, and object properties.

  • Basic Patterns: Describes constant, relational, type, and declaration patterns with examples.

  • Switch Expression: Switch expressions introduced in C# 8.0 allow chaining of pattern matching conditions, which results to more concise and readable code compared to traditional if-else statements.

  • Deconstruct method is used to extract specific properties from objects for easier manipulation.

  • You can combine multiple relational operators with logical operators (&& - and, || - or) to create complex conditions within a single pattern.

  • Discard Pattern: Use the _ pattern to ignore irrelevant values during matching.

  • Deconstruction Patterns: Describes deconstruction patterns and their use in extracting information from objects. Pattern matching facilitates deconstructing objects to extract specific properties, simplifying data manipulation.

  • Null Coalescing and Ternary Operators: Briefly discusses null coalescing and ternary operators for handling null values and conditional operations.

  • Special Patterns: Covers var pattern and list pattern, explaining their syntax and applications with examples.

  • Using clear and descriptive patterns, code becomes easier to understand and scalable.

Sourcecode.

ย