Pattern Matching
By the end of this lesson
Test shape and extract values in one step.
A pattern is a test of shape that hands you the value at the same time. That combination is the whole idea. Instead of asking "is this a card payment?", then casting it to a card payment, then reading a property from it, you do all three in one piece of syntax and the compiler keeps them in step.
This matters because the old version had a gap between the test and the cast. Two separate statements mentioning two type names, and nothing forcing them to agree. Pattern matching closes that gap.
public abstract class PaymentMethod
{
public decimal Amount { get; init; }
}
public class CardPayment : PaymentMethod { public string Network { get; init; } = ""; }
public class BankTransfer : PaymentMethod { }
public class CashOnDelivery : PaymentMethod { }
public static decimal ProcessingFee(PaymentMethod method)
{
if (method is CardPayment)
{
CardPayment card = (CardPayment)method; // the type is named twice
return card.Amount * 0.019m;
}
BankTransfer? transfer = method as BankTransfer;
if (transfer != null) // cast first, then check for null
{
return transfer.Amount > 100_000m ? 0m : 25m;
}
if (method is CashOnDelivery)
{
return 40m;
}
throw new NotSupportedException("Unknown payment method.");
}- The first branch names CardPayment twice. Nothing stops a later edit changing one and not the other, and the resulting cast failure appears at run time.
- The second branch uses as, which returns null instead of throwing when the type does not match. That works, but it needs a null check underneath it and a nullable variable to hold the result.
- Read down the method and count how much of it is mechanism rather than pricing rules. That ratio is what pattern matching improves.
public static decimal ProcessingFee(PaymentMethod method) => method switch
{
null => throw new ArgumentNullException(nameof(method)),
CardPayment card => card.Amount * 0.019m,
BankTransfer { Amount: > 100_000m } => 0m,
BankTransfer => 25m,
CashOnDelivery => 40m,
_ => throw new NotSupportedException($"No fee rule for {method.GetType().Name}.")
};- method switch { ... } is a switch expression. It produces a value, which is why the whole method can be a single =>. Each arm is pattern => result.
- CardPayment card is a declaration pattern: if the test passes, card is a CardPayment variable available in that arm only. The type is named once.
- BankTransfer { Amount: > 100_000m } combines a type pattern with a property pattern and a relational pattern. Read it as "a bank transfer whose Amount is greater than 100,000".
- Arms are tried top to bottom and the first match wins. The plain BankTransfer arm below the more specific one catches every transfer that did not qualify for the free tier. Swap those two lines and the free tier becomes unreachable.
- _ is the discard pattern and matches anything. It is the catch-all, and the next callout explains why leaving it out is risky.
- The null arm comes first because a null reference matches no type pattern. Without it, null would fall through to the discard arm, and GetType() there would throw the wrong exception.
The patterns worth knowing by name:
- Type pattern
- method is CardPayment — true when the value is that type. Add a name to get a variable: method is CardPayment card.
- Constant pattern
- status is OrderStatus.Cancelled, or count is 0. Compares against a compile-time constant, including null.
- Property pattern
- order is { Status: OrderStatus.Draft }. Tests members of the value. It can go deeper: { Customer.State: "MH" }.
- Relational pattern
- amount is > 100_000m. Any of <, <=, > and >= against a constant.
- Logical patterns
- Combine with and, or and not: amount is > 0m and < 500m, or method is not CashOnDelivery.
- when clause
- An extra condition on an arm for anything patterns cannot express: CardPayment card when IsBlocked(card.Network) => ...
- Discard
- _ matches anything and binds nothing. As a switch arm it is the default case.
public record Destination(string Country, string State);
public class Order
{
public OrderStatus Status { get; init; }
public decimal TotalAmount { get; init; }
public Destination? ShipTo { get; init; }
}
public static string ShippingBand(Order order) => order switch
{
{ Status: OrderStatus.Cancelled } => "None",
{ ShipTo.Country: "IN", TotalAmount: >= 5_000m } => "Free domestic",
{ ShipTo.Country: "IN" } => "Standard domestic",
{ ShipTo: null } => "Awaiting address",
{ TotalAmount: > 0m and < 20_000m } => "International economy",
{ TotalAmount: >= 20_000m } => "International express",
_ => "Manual review"
};- No type name appears in the arms, because every arm matches the same type. A property pattern on its own, { Status: ... }, tests the value you switched on.
- ShipTo.Country reaches through one property into another. If ShipTo is null the pattern does not match and nothing is dereferenced — property patterns check for null before they look inside. That is why this code cannot throw even though ShipTo is nullable.
- Because that null does not crash, it also does not announce itself. The { ShipTo: null } arm is there to give it a deliberate answer rather than letting it drift into a later arm.
- TotalAmount: > 0m and < 20_000m is one pattern, not two comparisons joined by boolean logic. It reads closer to how the rule would be written on paper.
- The rules are now in the same order and the same shape as a pricing table, which makes a review conversation with whoever owns the rules possible.
Summary
- A pattern tests shape and extracts the value in one step, so a type is never named twice
- Patterns build up: type, constant, property, relational, and the logical combinations of them
- A switch expression produces a value, tries arms top to bottom, and takes the first match
- An unmatched switch expression throws at run time, so a deliberate catch-all arm is part of the design
- When you own the types and the behaviour belongs to them, a virtual method gives you compile-time safety that a switch cannot
Practice
Attempt each one before opening the solution. Getting it wrong first is how the idea sticks.
Try it yourself
Try it yourself
Rewrite this as a single switch expression: an invoice is Overdue when its due date has passed and it is unpaid, Paid when the paid amount equals the total, PartlyPaid when some amount has been paid, and Open otherwise. A cancelled invoice is always Cancelled.
Then add a rule that an invoice with a total of zero is Void, and check whether your arm order still gives the right answer.
Show solution
Specific arms go above general ones. Cancelled comes first because it overrides everything. Void has to sit above Paid, because a zero total with zero paid satisfies AmountPaid equal to Total and would otherwise report as Paid — that is the ordering trap the exercise is built around.
The overdue rule needs a when clause rather than a pattern, because it compares two values rather than one value against a constant. Patterns test against constants; when handles everything else.
The final arm is a discard with a real answer rather than an exception, because Open is a genuine state and not an unexpected one. Whether your catch-all should return a value or throw depends on whether the unmatched case is a legitimate outcome or a bug, and that is a decision worth making consciously each time.
public static string InvoiceState(Invoice invoice, DateOnly today) => invoice switch
{
{ Status: InvoiceStatus.Cancelled } => "Cancelled",
{ Total: 0m } => "Void",
var i when i.AmountPaid >= i.Total => "Paid",
var i when i.DueDate < today && i.AmountPaid < i.Total => "Overdue",
{ AmountPaid: > 0m } => "PartlyPaid",
_ => "Open"
};Think about it
Think about it
A team replaces an abstract ProcessingFee method on PaymentMethod with one switch expression in a pricing service. Six months later they add a UpiPayment type.
What does the compiler do in each design, and where does the difference show up?
Show solution
With the abstract method, the new type does not compile until ProcessingFee is implemented. The reminder arrives at the moment the type is created, from the compiler, with no way to skip it.
With the switch, the new type compiles immediately. If the switch has a discard arm that throws, the failure appears the first time a customer pays by UPI — in production, on a real order. If the discard arm returns a default fee, there is no failure at all — only quietly wrong pricing that may go unnoticed for months.
So the difference is not a matter of taste. It is where the work of remembering lives: in the compiler, or in a person. That said, the switch is still the better choice when the rule genuinely belongs to the pricing service rather than to the payment type, because the alternative is a payment class that knows about regional fee policy. In that case the protection you want is a discard arm that throws loudly, plus a test that covers every type.
Knowledge check
Nothing is recorded and there is no score. The explanation appears either way.
Saved in this browser only.