Pattern Matching
Master modern switch expressions and pattern matching for elegant control flow
Pattern Matching in C#
Master pattern matching in C# with free flashcards and spaced repetition practice. This lesson covers declaration patterns, type patterns, property patterns, relational patterns, and logical patternsβessential concepts for writing expressive and maintainable modern C# code.
Welcome to Pattern Matching! π»
Pattern matching is one of the most powerful features introduced in modern C#. Starting from C# 7.0 and expanding significantly through C# 8.0, 9.0, 10.0, and beyond, pattern matching allows you to test whether a value has a certain "shape" and extract information from it when it does. This makes your code more readable, concise, and less error-prone compared to traditional if-else chains or type casting.
Why Pattern Matching Matters:
- π― More Expressive: Write code that clearly states your intent
- π Type-Safe: Compiler-verified type checks and conversions
- π Less Boilerplate: Eliminate repetitive casting and null checks
- π§© Better Maintainability: Easier to understand and modify complex conditional logic
Core Concepts π§
What is Pattern Matching?
At its core, pattern matching allows you to test a value against a pattern and conditionally extract information from that value. Think of it as asking questions about your data: "Is this a string?", "Does this object have a Length property greater than 5?", "Is this value between 1 and 10?".
The Evolution of Pattern Matching
| C# Version | Pattern Types Introduced |
|---|---|
| C# 7.0 | Type patterns, constant patterns, var patterns |
| C# 8.0 | Property patterns, tuple patterns, positional patterns |
| C# 9.0 | Relational patterns, logical patterns (and, or, not) |
| C# 10.0 | Extended property patterns, list patterns |
| C# 11.0 | List pattern improvements |
Type Patterns π
Type patterns test whether a value is of a specific type and, if so, convert it to that type.
Syntax: expression is Type variableName
Before pattern matching, you'd write:
if (obj is string)
{
string s = (string)obj;
Console.WriteLine(s.ToUpper());
}
With type patterns:
if (obj is string s)
{
Console.WriteLine(s.ToUpper());
}
The variable s is automatically declared and scoped to the if block when the pattern matches.
Declaration Patterns π
Declaration patterns combine type testing with variable declaration. They're the foundation of many other pattern types.
public static string GetDescription(object obj)
{
return obj switch
{
int i => $"Integer: {i}",
string s => $"String of length {s.Length}",
DateTime dt => $"Date: {dt:yyyy-MM-dd}",
null => "Null value",
_ => "Unknown type"
};
}
π‘ Tip: The switch expression (introduced in C# 8.0) is often cleaner than traditional switch statements for pattern matching.
Constant Patterns π²
Constant patterns test whether a value equals a specific constant.
public static bool IsWeekend(DayOfWeek day)
{
return day is DayOfWeek.Saturday or DayOfWeek.Sunday;
}
Property Patterns π
Property patterns let you match on properties of an object. This is incredibly powerful for working with complex data structures.
Syntax: expression is Type { Property: pattern, ... }
public class Person
{
public string Name { get; set; }
public int Age { get; set; }
public string City { get; set; }
}
public static string CheckPerson(Person person)
{
return person switch
{
{ Age: < 18 } => "Minor",
{ Age: >= 18, Age: < 65 } => "Adult",
{ Age: >= 65 } => "Senior",
null => "No person"
};
}
You can nest property patterns:
public class Order
{
public Customer Customer { get; set; }
public decimal Total { get; set; }
}
public class Customer
{
public string Name { get; set; }
public bool IsPremium { get; set; }
}
public static decimal CalculateDiscount(Order order)
{
return order switch
{
{ Customer: { IsPremium: true }, Total: > 1000 } => 0.15m,
{ Customer: { IsPremium: true }, Total: > 500 } => 0.10m,
{ Customer: { IsPremium: true } } => 0.05m,
{ Total: > 1000 } => 0.10m,
_ => 0m
};
}
Relational Patterns βοΈ
Relational patterns (C# 9.0+) use comparison operators: <, <=, >, >=.
public static string GetTemperatureDescription(int celsius)
{
return celsius switch
{
< 0 => "Freezing",
>= 0 and < 10 => "Cold",
>= 10 and < 20 => "Cool",
>= 20 and < 30 => "Warm",
>= 30 => "Hot"
};
}
Logical Patterns π
Logical patterns (C# 9.0+) combine patterns using and, or, and not.
public static bool IsValidAge(int age)
{
return age is >= 0 and <= 120;
}
public static string ClassifyNumber(int number)
{
return number switch
{
0 => "Zero",
> 0 and < 10 => "Single digit positive",
< 0 and > -10 => "Single digit negative",
>= 10 or <= -10 => "Multiple digits"
};
}
public static bool IsNotNull(object obj)
{
return obj is not null;
}
Positional Patterns π
For types that support deconstruction (like tuples or types with a Deconstruct method), you can use positional patterns.
public static string GetQuadrant(double x, double y)
{
return (x, y) switch
{
(0, 0) => "Origin",
(> 0, > 0) => "Quadrant I",
(< 0, > 0) => "Quadrant II",
(< 0, < 0) => "Quadrant III",
(> 0, < 0) => "Quadrant IV",
(_, 0) => "X-axis",
(0, _) => "Y-axis"
};
}
List Patterns π
List patterns (C# 11.0+) allow you to match sequences and arrays.
public static string DescribeArray(int[] array)
{
return array switch
{
[] => "Empty array",
[1] => "Array with single element: 1",
[1, 2] => "Array: [1, 2]",
[1, ..] => "Array starting with 1",
[.., 9] => "Array ending with 9",
[1, .., 9] => "Array starting with 1 and ending with 9",
_ => "Other array"
};
}
The .. syntax is called a slice pattern and matches zero or more elements.
Var Patterns π¦
Var patterns always match and assign the value to a variable. They're useful when you need to capture a value for further processing.
public static bool IsLongString(object obj)
{
return obj is var value && value is string s && s.Length > 10;
}
Detailed Examples with Explanations π
Example 1: Building a Shape Calculator πΊ
Let's create a shape hierarchy and use pattern matching to calculate areas:
public abstract class Shape { }
public class Circle : Shape
{
public double Radius { get; set; }
}
public class Rectangle : Shape
{
public double Width { get; set; }
public double Height { get; set; }
}
public class Triangle : Shape
{
public double Base { get; set; }
public double Height { get; set; }
}
public static double CalculateArea(Shape shape)
{
return shape switch
{
Circle { Radius: var r } => Math.PI * r * r,
Rectangle { Width: var w, Height: var h } => w * h,
Triangle { Base: var b, Height: var h } => 0.5 * b * h,
null => throw new ArgumentNullException(nameof(shape)),
_ => throw new ArgumentException("Unknown shape type")
};
}
public static string DescribeShape(Shape shape)
{
return shape switch
{
Circle { Radius: > 10 } => "Large circle",
Circle { Radius: <= 10 and > 5 } => "Medium circle",
Circle { Radius: <= 5 } => "Small circle",
Rectangle { Width: var w, Height: var h } when w == h => "Square",
Rectangle { Width: > 20, Height: > 20 } => "Large rectangle",
Rectangle => "Rectangle",
Triangle { Base: > 0, Height: > 0 } => "Valid triangle",
_ => "Unknown shape"
};
}
Explanation:
- Property patterns extract the
Radius,Width, andHeightvalues - Relational patterns (
> 10,<= 10 and > 5) classify circles by size - The
whenclause adds additional boolean conditions - The
varkeyword in property patterns captures the value for use in the expression
Example 2: HTTP Response Handler π
Pattern matching shines when handling different HTTP status codes:
public record HttpResponse(int StatusCode, string Body, Dictionary<string, string> Headers);
public static string HandleResponse(HttpResponse response)
{
return response switch
{
{ StatusCode: 200, Body: { Length: > 0 } }
=> $"Success: {response.Body}",
{ StatusCode: 200, Body: "" }
=> "Success but empty response",
{ StatusCode: >= 200 and < 300 }
=> "Successful request",
{ StatusCode: 301 or 302, Headers: var h } when h.ContainsKey("Location")
=> $"Redirect to: {h["Location"]}",
{ StatusCode: 400 }
=> "Bad request",
{ StatusCode: 401 or 403 }
=> "Unauthorized or forbidden",
{ StatusCode: 404 }
=> "Resource not found",
{ StatusCode: >= 400 and < 500 }
=> "Client error",
{ StatusCode: >= 500 }
=> "Server error",
_ => "Unknown status code"
};
}
Explanation:
- Combines property patterns with relational patterns
- Uses logical
orto handle multiple status codes - The
whenguard checks dictionary contents - Nested property patterns check
Body.Length
Example 3: Investment Portfolio Analyzer π°
A real-world example analyzing investment types:
public abstract class Investment
{
public decimal Amount { get; set; }
public DateTime PurchaseDate { get; set; }
}
public class Stock : Investment
{
public string Symbol { get; set; }
public decimal SharePrice { get; set; }
}
public class Bond : Investment
{
public decimal InterestRate { get; set; }
public DateTime MaturityDate { get; set; }
}
public class RealEstate : Investment
{
public string Address { get; set; }
public decimal RentalIncome { get; set; }
}
public static string AnalyzeInvestment(Investment investment, DateTime currentDate)
{
var holdingPeriod = (currentDate - investment.PurchaseDate).Days;
return investment switch
{
Stock { Amount: > 100000, SharePrice: > 500 }
=> "High-value tech stock position",
Stock { Symbol: "AAPL" or "MSFT" or "GOOGL" }
=> "Major tech company stock",
Stock s when holdingPeriod > 365
=> $"Long-term stock holding: {s.Symbol}",
Bond { InterestRate: > 0.05m, MaturityDate: var md } when md > currentDate
=> "High-yield bond, not yet mature",
Bond { MaturityDate: var md } when md <= currentDate
=> "Matured bond - action required",
RealEstate { RentalIncome: > 0, Amount: > 500000 }
=> "High-value income-generating property",
RealEstate { RentalIncome: 0 }
=> "Non-income property (appreciation play)",
{ Amount: < 10000 }
=> "Small investment position",
_ => "Standard investment"
};
}
Explanation:
- Combines multiple pattern types in a single
switchexpression - Uses
whenclauses for complex time-based logic - Property patterns with logical
orfor multiple stock symbols - The base class property
Amountis accessible in all patterns
Example 4: Recursive Data Structure Processing π³
Pattern matching is excellent for processing tree-like structures:
public abstract class JsonValue { }
public class JsonObject : JsonValue
{
public Dictionary<string, JsonValue> Properties { get; set; }
}
public class JsonArray : JsonValue
{
public List<JsonValue> Items { get; set; }
}
public class JsonString : JsonValue
{
public string Value { get; set; }
}
public class JsonNumber : JsonValue
{
public double Value { get; set; }
}
public class JsonBoolean : JsonValue
{
public bool Value { get; set; }
}
public class JsonNull : JsonValue { }
public static int CountValues(JsonValue json)
{
return json switch
{
JsonNull => 0,
JsonString or JsonNumber or JsonBoolean => 1,
JsonArray { Items: var items } => items.Sum(CountValues),
JsonObject { Properties: var props } => props.Values.Sum(CountValues),
_ => 0
};
}
public static string Stringify(JsonValue json, int indent = 0)
{
var spaces = new string(' ', indent * 2);
return json switch
{
JsonNull => "null",
JsonBoolean { Value: var b } => b.ToString().ToLower(),
JsonNumber { Value: var n } => n.ToString(),
JsonString { Value: var s } => $"\"{s}\"",
JsonArray { Items: [] } => "[]",
JsonArray { Items: [var single] } => $"[{Stringify(single)}]",
JsonArray { Items: var items } =>
$"[\n{spaces} " +
string.Join($",\n{spaces} ", items.Select(i => Stringify(i, indent + 1))) +
$"\n{spaces}]",
JsonObject { Properties: var props } when props.Count == 0 => "{}",
JsonObject { Properties: var props } =>
"{\n" +
string.Join(",\n", props.Select(kvp =>
$"{spaces} \"{kvp.Key}\": {Stringify(kvp.Value, indent + 1)}")) +
$"\n{spaces}}}",
_ => "undefined"
};
}
Explanation:
- List patterns with
[]match empty arrays - List patterns with
[var single]match single-element arrays - Recursive calls within pattern match arms
- Logical
orcombines multiple simple types whenclauses check collection sizes
Common Mistakes β οΈ
1. Forgetting Pattern Order Matters
β Wrong:
return value switch
{
_ => "Default", // This matches everything!
> 0 and < 10 => "Single digit", // Never reached
>= 10 => "Multiple digits" // Never reached
};
β Correct:
return value switch
{
> 0 and < 10 => "Single digit",
>= 10 => "Multiple digits",
_ => "Default" // Always last
};
Why: Patterns are evaluated top-to-bottom. The first matching pattern wins. The discard pattern _ matches everything, so it must come last.
2. Not Handling All Cases
β Wrong:
return shape switch
{
Circle c => CalculateCircleArea(c),
Rectangle r => CalculateRectangleArea(r)
// Compiler warning: not all cases handled!
};
β Correct:
return shape switch
{
Circle c => CalculateCircleArea(c),
Rectangle r => CalculateRectangleArea(r),
_ => throw new ArgumentException("Unknown shape")
};
Why: The compiler expects exhaustive pattern matching. Always include a default case with _ unless you've covered all possibilities.
3. Incorrect Null Handling
β Wrong:
return obj switch
{
string s when s.Length > 5 => "Long string", // NullReferenceException if obj is null!
_ => "Other"
};
β Correct:
return obj switch
{
null => "Null value",
string s when s.Length > 5 => "Long string",
string s => "Short string",
_ => "Other"
};
Why: Always check for null first, or use not null pattern to ensure safety.
4. Overcomplicating with When Clauses
β Wrong:
return person switch
{
{ Age: var a } when a >= 18 && a < 65 => "Adult", // Unnecessary
_ => "Other"
};
β Correct:
return person switch
{
{ Age: >= 18 and < 65 } => "Adult", // Cleaner with relational patterns
_ => "Other"
};
Why: Use relational and logical patterns instead of when clauses when possible. They're more readable and efficient.
5. Forgetting Type Safety
β Wrong:
if (obj is string s)
{
// ... many lines of code ...
}
Console.WriteLine(s.ToUpper()); // Compiler error: s not in scope!
β Correct:
if (obj is string s)
{
Console.WriteLine(s.ToUpper()); // Use s within the scope
}
Why: Pattern variables are scoped to their containing block. They're not accessible outside.
6. Not Using Property Patterns Effectively
β Wrong:
return order switch
{
Order o when o.Customer != null && o.Customer.IsPremium && o.Total > 1000 => 0.15m,
_ => 0m
};
β Correct:
return order switch
{
{ Customer.IsPremium: true, Total: > 1000 } => 0.15m,
_ => 0m
};
Why: Property patterns are more concise and handle null checking automatically at each level.
7. Mixing Switch Statement with Switch Expression Syntax
β Wrong:
string result = value switch
{
> 0 => "Positive"; // Semicolon instead of comma!
< 0 => "Negative"; // Switch expressions use =>, not :
_ => "Zero"
};
β Correct:
string result = value switch
{
> 0 => "Positive", // Comma separator
< 0 => "Negative",
_ => "Zero" // No semicolon on last one
};
Why: Switch expressions use => and commas, not colons and semicolons like switch statements.
π§ Memory Tips & Tricks
π― The PRCLV Mnemonic for Pattern Types:
- Property patterns - check object properties
- Relational patterns - use comparison operators
- Constant patterns - match specific values
- Logical patterns - combine with and/or/not
- Var patterns - capture any value
π‘ Pattern Matching Decision Flow:
βββββββββββββββββββββββββββββββββββββββββββ
β Need to check a value? β
ββββββββββββββ¬βββββββββββββββββββββββββββββ
β
βΌ
Is it a type check?
β
ββββββββ΄βββββββ
β β
YES NO
β β
βΌ βΌ
Use "is Type Need properties?
variable" β
βΌ
Use property pattern
{ Prop: pattern }
π§ Quick Reference: When to Use Each Pattern
π Pattern Matching Quick Reference
| Use Case | Pattern Type | Example |
|---|---|---|
| Check type | Type pattern | obj is string s |
| Check specific value | Constant pattern | x is 42 |
| Check object properties | Property pattern | { Age: > 18 } |
| Compare numbers | Relational pattern | x is >= 0 and < 10 |
| Combine conditions | Logical pattern | x is > 0 or < -5 |
| Match tuple values | Positional pattern | (x, y) is (0, 0) |
| Match list elements | List pattern | [1, .., 9] |
| Capture value | Var pattern | is var x |
| Default case | Discard pattern | _ |
π― Real-World Scenarios
Scenario 1: API Response Handling
You're building a REST API client and need to handle different response types elegantly:
public async Task<Result> FetchDataAsync(string endpoint)
{
var response = await httpClient.GetAsync(endpoint);
return (response.StatusCode, await response.Content.ReadAsStringAsync()) switch
{
(HttpStatusCode.OK, var content) when !string.IsNullOrEmpty(content)
=> Result.Success(content),
(HttpStatusCode.OK, _)
=> Result.Failure("Empty response"),
(HttpStatusCode.NotFound, _)
=> Result.Failure("Resource not found"),
(HttpStatusCode.Unauthorized or HttpStatusCode.Forbidden, _)
=> Result.Failure("Access denied"),
(var code, _) when (int)code >= 500
=> Result.Failure("Server error"),
_ => Result.Failure("Unknown error")
};
}
Scenario 2: Game State Management
In a game, you need to determine valid player actions based on game state:
public record GameState(int Health, int Ammo, bool HasKey, string Location);
public static List<string> GetAvailableActions(GameState state)
{
return state switch
{
{ Health: <= 0 }
=> new List<string> { "Respawn", "Quit" },
{ Health: < 20, Location: "Hospital" }
=> new List<string> { "Heal", "Rest", "Leave" },
{ Ammo: > 0, Health: > 20 }
=> new List<string> { "Shoot", "Move", "Reload", "Inventory" },
{ Ammo: 0, Health: > 20 }
=> new List<string> { "Move", "FindAmmo", "Inventory" },
{ HasKey: true, Location: "LockedDoor" }
=> new List<string> { "UnlockDoor", "Move", "Inventory" },
{ Location: "SafeRoom" }
=> new List<string> { "Rest", "SaveGame", "ManageInventory", "Leave" },
_ => new List<string> { "Move", "Inventory" }
};
}
Key Takeaways π
Pattern matching makes code more expressive: Instead of nested
if-elsechains, express your intent directlyOrder matters: Patterns are evaluated top-to-bottom; more specific patterns should come before general ones
Always handle all cases: Use the discard pattern
_as a catch-all to ensure exhaustivenessCombine pattern types: Mix property, relational, and logical patterns for powerful expressions
Prefer switch expressions over switch statements: They're more concise and enforce returning a value
Use property patterns for object inspection: They're cleaner than manual property access and null checks
Relational and logical patterns (C# 9.0+) reduce when clauses: Write
x is >= 0 and < 10instead ofx when x >= 0 && x < 10List patterns (C# 11.0+) simplify sequence matching: Great for matching arrays and lists by structure
Type safety comes free: Pattern variables are strongly typed and scoped appropriately
Performance is good: The compiler optimizes pattern matching efficiently
π Further Study
- Microsoft Docs: Pattern Matching in C#
- C# 11 What's New: List Patterns
- C# Pattern Matching Tutorial by Nick Chapsas
π Congratulations! You've mastered the fundamentals of pattern matching in C#. This powerful feature will make your code more maintainable, type-safe, and expressive. Practice by refactoring existing if-else chains and type casting code into pattern matching expressions!