Vaughan Reid's blog

A case for pattern matching

Over the years developers using more functional languages like F# have boasted about how much easier their code is to read and how imperative C# can be. NO LONGER! Well maybe not much longer.

We are starting to move in the right direction with pattern matching as a good replacement for switch statements. switch statements are very useful but the general syntax has been around for more than 50 years. Starting from C# 7.0, we could start using pattern matching as an alternative. It seems an active feature because each release has made it more powerful.

I want to show an example how the combination of pattern matching and value tuples can create very readable code.

Imagine your application has to calculate ticket prices for a zoo based on different rules for the day of the week and the age group of the person. This below is I think a reasonable way to implement it before pattern matching.

public enum Age { Child, Adult, Senior }

private Age GetAgeGroup(int age)
{
	if (age < 13) return Age.Child;
	if (age < 60) return Age.Adult;
	return Age.Senior;
}

/*
* Base price   - R100
* Children     - Half price
* Seniors      - Half price on Wednesdays
* Adults       - 25% off Weekends
*/
public decimal GetTicketPrice(int age, DateTime date)
{
	DayOfWeek dayOfWeek = date.DayOfWeek;
	Age ageGroup = GetAgeGroup(age);

	if(ageGroup == Age.Child)
	{
		return 50;
	}

	if(ageGroup == Age.Adult && 
	   (dayOfWeek == DayOfWeek.Saturday ||
		dayOfWeek == DayOfWeek.Sunday))
	{
		return 75;
	}

	if (ageGroup == Age.Senior &&
		dayOfWeek == DayOfWeek.Wednesday)
	{
		return 75;
	}

	return 100;
}

Its not terrible but the GetTicketPrice has to walk through all the scenarios in quite a few lines. As the rules become more complex it may become easier to have subtle bugs. Below is the same method but its doing a few things at once. Firstly its creating a value tuple of (Age, DayOfWeek) and then its using pattern matching to match it to the correct option. The discard (_) option at the end is equivalent to the switch default case.

/*
* Base price   - R100
* Children     - Half price
* Seniors      - Half price on Wednesdays
* Adults       - 25% off Weekends
*/
public decimal GetTicketPrice(int age, DateTime date)
{
	return (GetAgeGroup(age), date.DayOfWeek) switch
	{
		( Age.Child, DayOfWeek.Monday) => 50,
		( Age.Child, DayOfWeek.Tuesday) => 50,
		( Age.Child, DayOfWeek.Wednesday) => 50,
		( Age.Child, DayOfWeek.Thursday) => 50,
		( Age.Child, DayOfWeek.Friday) => 50,
		( Age.Child, DayOfWeek.Saturday) => 50,
		( Age.Child, DayOfWeek.Sunday) => 50,
		( Age.Adult, DayOfWeek.Saturday) => 75,
		( Age.Adult, DayOfWeek.Sunday) => 75,
		( Age.Senior, DayOfWeek.Wednesday) => 50,
		_ => 100
	};
}

One problem with the above statement is that even though children are 50 for every day of the week, I added a pattern for each day. You don’t actually need to do it. By using the discard character again, it tells the pattern that any value will match.


/*
* Base price   - R100
* Children     - Half price
* Seniors      - Half price on Wednesdays
* Adults       - 25% off Weekends
*/
public decimal GetTicketPrice(int age, DateTime date)
{
	return (GetAgeGroup(age), date.DayOfWeek) switch
	{
		( Age.Child, _) => 50,
		( Age.Adult, DayOfWeek.Saturday) => 75,
		( Age.Adult, DayOfWeek.Sunday) => 75,
		( Age.Senior, DayOfWeek.Wednesday) => 50,
		_ => 100
	};
}

You have to admit that this is much more readable compared to the original version. Whats great is that pattern matching is still getting new features at each new major C# release with Type, Relational and Logic patterns coming in .NET 5.