Pattern matching and record types in C# 9

Enrico Buonanno
6 min readOct 7, 2021

Over the last few months, I've been working on the 2nd edition of my book on Functional Programming in C#. Many of the updates had to do with removing stuff. What I means is that, when I wrote the 1st edition in 2017, targeting C# 7, I had to jump through hoops and "bend" the language to be able to demonstrate several functional techniques in C#.

The 2nd edition targets C# 10, and therefore can take advantage of new language features that are very important in functional programming. In particular:

  • Pattern matching (introduced incrementally, but this feature really came into its own in C# 8, with the introduction of switch expressions)
  • Record types (introduced in C# 9)

These two features work very well in tandem, and in the rest of this article (which is extract from Chapter 1 of my book) I'll illustrate this through a practical example.

If you’ve worked in e-commerce, you may have come across the need to evaluate the value-added tax (VAT) that your customers will pay with their
purchase (VAT is also called sales tax or consumption tax, depending on the country you’re in).

Imagine you need to write a function that estimates the VAT a customer needs to pay on an order. The logic and amount of VAT depends on the country to which the item is sent and, of course, on the purchase amount. Therefore, we’re looking to implement a function named Vatthat will compute a decimal (the tax amount), given an Order and the buyer’s Address.

Assume that the requirements are as follows:

  • For goods shipped to Italy and Japan, VAT will be charged at a fixed rate of 22% and 8%, respectively.
  • Germany charges 8% on food products and 20% on all other products.
  • The USA charges a fixed rate on all products, but the rate varies for each state.

Before you read on, take a moment to think how you would go about tackling this task.

The following listing shows how you can use record types to model an Order. To keep things simple, I’m assuming that an Order cannot contain different types of Product.

record Product(string Name, decimal Price, bool IsFood);record Order(Product Product, int Quantity)
{
public decimal NetPrice => Product.Price * Quantity;
}

Notice how with a single line, you can define the Product type! The compiler generates a constructor, property getters, and several convenience methods such as Equals, GetHashCode, and ToString for you. (Note that Product lacks a body and ends with a semicolon, while Order has a body including additional members.)

TIP: Records in C# 9 are reference types, but C# 10 allows you to use record syntax to define value types by simply writing record struct rather than just record.

Let’s start by implementing the first rule for countries like Italy and Japan that have fixed VAT rates. As a reminder, goods shipped to Italy and Japan are charged at a fixed rate of 22% and 8%, respectively.

static decimal Vat(Address address, Order order)
=> Vat(RateByCountry(address.Country), order);
static decimal RateByCountry(string country)
=> country switch
{
"it" => 0.22m,
"jp" => 0.08m,
_ => throw new ArgumentException($"No rate for {country}")
};
static decimal Vat(decimal rate, Order order)
=> order.NetPrice * rate;

Here, I’ve defined RateByCountry to map country codes to their respective VAT rates. Notice the clean syntax of a switch expression compared to the
traditional switch statement with its clunky use of case, break, and return. Here we simply match on the value of country.

Also notice that the previous code assumes there is an Address type with a Country property. This can be defined as follows:

record Address(string Country);

What about the other fields that make up an address, like the street, postal
code, and so on? No, I didn’t forget or leave them out for simplicity. Because we only require the country for this calculation, it’s legitimate to have an Address type that only encapsulates the information we need in this context. You could have a different, richer definition of Address in a different component defining a conversion between the two, if required.

Let’s move on and add the implementation for goods shipped to Germany.
As a reminder, Germany charges 8% on food products and 20% on all other products. The following listing shows how to add this rule.

static decimal Vat(Address address, Order order)
=> address switch
{
Address("de") => DeVat(order),
Address(var country) => Vat(RateByCountry(country), order),
};
static decimal DeVat(Order order)
=> order.NetPrice * (order.Product.IsFood ? 0.08m : 0.2m);

We’ve now added a switch expression within the Vat function. In each case, the given Address is deconstructed, allowing us to match on the value of its Country. In the first case, we match it against the literal value “de”; if this matches, we call the VAT computation for Germany, DeVat. In the second case, the value is assigned to the country variable and we retrieve the rate by country as previously. Note that it’s possible to simplify the clauses of the switch expression as follows:

static decimal Vat(Address address, Order order)
=> address switch
{
("de") _ => DeVat(order),
(var country) _ => Vat(RateByCountry(country), order),
};

Because the type of address is known to be Address, you can omit the type, but in this case, you must include a variable name for the matching expression (here we use a discard, the underscore character).

Now for the USA. Here we also need to know the state to which the order is going because different states apply different rates. You can model this as follows:

record Address(string Country);
record UsAddress(string State) : Address(“us”);

That is, we create a dedicated type to represent a US address. This extends Address because it has additional data. (In my opinion, this is better than adding a State property to Address and having it be null for the majority of countries.) We can now complete our requirements as the following listing shows.

static decimal Vat(Address address, Order order)
=> address switch
{
UsAddress(var state) => Vat(RateByState(state), order),
("de") _ => DeVat(order),
(var country) _ => Vat(RateByCountry(country), order),
};
static decimal RateByState(string state)
=> state switch
{
"ca" => 0.1m,
"ma" => 0.0625m,
"ny" => 0.085m,
_ => throw new ArgumentException($"Missing rate for {state}")
};

RateByState is implemented along the same lines as RateByCountry. What’s more interesting is the pattern matching in Va. We can now match on the UsAddress type, extracting the state, to find the rate applicable to that state.

And we’re done! The whole thing is just over 40 lines; most functions are one-liners; and the three cases in our requirements are clearly expressed in the corresponding cases in the top-level switch expression.

We didn’t need to go crazy with functions (yet). We didn’t need to create an interface with multiple implementations as an OO programmer (seeing this
problem as a perfect candidate for the strategy pattern) probably would have. Instead, we just used a type-driven approach that is representative of how records and pattern matching can be used in statically-typed functional languages.

The resulting code is not only concise, but it is also readable and extensible. You can see that it would be easy for any programmer to come in and add new rules for other countries or modify the existing rules if required.

I hope that this example gave you some inspiration on how you can use types to represent different conditions or business rules in your domain, and use pattern matching to apply different logic according to those rules.

If you enjoyed this sample and would like to know more about how coding functionally in C# can be interesting, productive, and fun, please head to the Manning website to check out my book on Functional Programming in C#.

Thanks for reading.

--

--