When working with polymorphism in C#, you often deal with a base type that can represent many different derived types.
For example, in a payment system you might have a base class Payment, and several derived classes like CreditCardPayment, BankTransferPayment, and MobilePayment.
When you store these objects in a List<Payment>, the compiler only knows that each item is a Payment. But sometimes you need to access properties that exist only on the specific child types.
This is where type checking and casting come into play.
The Old Way: Manual Casting
Before modern pattern matching features were introduced, developers had to manually check the type and then cast the object.
Example:
if (payment is CreditCardPayment)
{
var cc = (CreditCardPayment)payment;
Console.WriteLine(cc.CardLastFour);
}
Here two steps are happening:
- Check the type with
is - Cast the object manually using
(CreditCardPayment)
This works, but it has a few problems:
- More boilerplate code
- Easy to forget the type check
- Harder to read when multiple types are involved
When you have several derived types, the code quickly becomes messy.
if (payment is CreditCardPayment)
{
var cc = (CreditCardPayment)payment;
}
else if (payment is BankTransferPayment)
{
var bt = (BankTransferPayment)payment;
}
else if (payment is MobilePayment)
{
var mp = (MobilePayment)payment;
}
This pattern was very common in older C# codebases.
The Modern Way: Pattern Matching
C# introduced pattern matching to simplify type checks and casting.
Instead of two steps, you can do both in a single line.
if (payment is CreditCardPayment cc)
{
Console.WriteLine(cc.CardLastFour);
}
What happens here?
The compiler:
- Checks if
paymentisCreditCardPayment - If true, automatically casts it
- Assigns the result to
cc
Inside the block, cc is already strongly typed.
No manual casting required.
Switch Pattern Matching
Pattern matching becomes even more powerful when used with switch.
Example:
var details = payment switch
{
CreditCardPayment cc => $"Credit Card ending in {cc.CardLastFour}",
BankTransferPayment bt => $"Bank Transfer via {bt.BankName}",
MobilePayment mp => $"{mp.Provider} | Phone: {mp.PhoneNumber}",
_ => "Unknown payment type"
};
Each branch automatically provides a typed variable.
So inside the first branch:
-
ccis aCreditCardPayment
Inside the second:
-
btis aBankTransferPayment
Inside the third:
-
mpis aMobilePayment
No casting required anywhere.
Why Pattern Matching is Better
1. Less Code
Manual casting requires extra lines and repeated type checks.
Pattern matching compresses the logic into a single clean expression.
2. Safer
Manual casting can throw exceptions if you forget the type check.
Pattern matching only runs the block if the type matches.
3. More Readable
Compare these two:
Manual casting:
if (payment is CreditCardPayment)
{
var cc = (CreditCardPayment)payment;
}
Pattern matching:
if (payment is CreditCardPayment cc)
{
}
The intent is much clearer.
4. Powerful with Switch Expressions
Pattern matching enables expressive switch logic that works very well with polymorphism.
It keeps the code structured and avoids large chains of if-else.
Real-World Use Case
In the payment system example, we store all payments in a single list:
List<Payment> payments
Each object could be a different type.
When generating a payment summary, we use pattern matching to safely access the correct properties for each payment type.
This allows the code to remain clean, type-safe, and maintainable.
When Should You Use Pattern Matching?
Use pattern matching when:
- You have a base class with multiple derived types
- You need to access properties specific to those types
- You want safer and cleaner code than manual casting
It is especially useful when working with:
- Polymorphism
- Domain models
- Event systems
- Result types
- API response handling
Final Thoughts
Manual casting was the standard approach for many years.
But modern C# encourages pattern matching because it is cleaner, safer, and easier to read.
Instead of writing extra checks and casts, you can let the language handle it for you.
Small improvements like this are why modern C# codebases feel significantly more expressive and maintainable.
Top comments (0)