Chapter 12: Pattern Matching
Pattern matching in C# lets you describe the shape of data rather than chase it through nested conditionals. The compiler checks the structure and types for you so your code reads like a clear explanation of intent. These features blend smoothly with .NET types such as records, tuples, collections, and primitives.
Type patterns and constant patterns
A type pattern checks whether a value is compatible with a specific type. If it is, the pattern can introduce a variable of that type. A constant pattern compares a value to a literal such as a number, a string, or null. You can combine these patterns with switch expressions or is expressions to write focused logic.
Using is with type patterns
The is keyword lets you test and assign at the same time. This helps avoid repeated casts or checks and keeps the flow readable.
object item = "hello";
if (item is string s)
{
Console.WriteLine(s.ToUpper());
}
Constant patterns in a switch expression
A constant pattern compares the input against known values. You often use this in small classification code or to handle discrete states.
string Classify(int code) => code switch
{
1 => "One",
2 => "Two",
3 => "Three",
_ => "Unknown"
};
The underscore pattern matches anything which makes it a good fallback in these cases.
Relational and logical patterns
Relational patterns let you form conditions with operators such as > and <=. Logical patterns combine subpatterns using and, or, and not. These patterns let you describe ranges and boundaries more naturally than a series of traditional if expressions.
Checking numeric ranges with relational patterns
Relational operators in patterns express the shape of numeric space directly. This improves the readability of validation code and threshold checks.
string Score(int value) => value switch
{
< 0 => "Invalid",
< 50 => "Low",
< 80 => "Medium",
_ => "High"
};
Combining patterns with logical connectors
The logical connectors help narrow or broaden matches. Combine them to craft rich descriptions of state.
bool IsAdult(int age) => age is (>= 18 and <= 120);
bool IsWeekend(DayOfWeek d) =>
d is (DayOfWeek.Saturday or DayOfWeek.Sunday);
bool IsNotEmpty(string? s) =>
s is not null and not "";
These patterns remain readable even as the underlying logic grows.
Property and positional patterns
Property patterns match on the values of named members. Positional patterns match values by unpacking them through a deconstruct method. Records in .NET work especially well here because they automatically generate a deconstructor that fits these patterns.
Matching named members with property patterns
Property patterns use braces and named members to match interior structure. You can match partial shapes which means you do not need to list every member.
record User(string Name, int Level);
string Describe(User u) => u switch
{
{ Level: >= 5 } => "Advanced user",
{ Level: < 5 } => "New user",
_ => "Unknown"
};
if statements.
Using positional patterns with records and tuples
Positional patterns rely on the order of elements returned by a deconstructor. Records generate this automatically although you can also define your own Deconstruct method for custom types.
record Point(int X, int Y);
string Quadrant(Point p) => p switch
{
( > 0, > 0 ) => "First",
( < 0, > 0 ) => "Second",
( < 0, < 0 ) => "Third",
( > 0, < 0 ) => "Fourth",
_ => "Axis"
};
Tuples match just as naturally since their elements already have a fixed position.
List patterns and slices
List patterns match sequences by length, ordering, and the shape of individual elements. Slices use the .. operator to match variable-length sections. These features help express rules for arrays, lists, and any type that implements the right collection interfaces.
Fixed and open-ended list patterns
A list pattern can match exact shapes or open-ended ones. You can specify expected elements or use a slice to gather the middle portion.
string Classify(int[] values) => values switch
{
[] => "Empty",
[0] => "Single zero",
[1, 2, 3] => "One two three",
[1, .. var tail] => $"Starts with one then {tail.Length} more",
_ => "Unknown"
};
Matching nested list structures
You can nest list patterns inside property patterns to inspect structured or tree-like shapes.
record Node(string Name, Node[] Children);
bool IsLeaf(Node n) => n is { Children: [] };
bool StartsWithChild(Node n) => n is
{
Children: [ { Name: "rootchild" }, .. ]
};
This kind of pattern creates a clear picture of hierarchical data.
Exhaustiveness and guard clauses
Switch expressions require all possible input shapes to be accounted for which gives you compile-time checking of completeness. A guard clause adds an extra condition to a pattern using when. This combination helps enforce correctness and clarity.
Ensuring full coverage in switch expressions
When the compiler knows all possible values of a type it checks for exhaustive handling. This works well for enum types and closed shapes such as records with limited patterns.
enum Light { Red, Yellow, Green }
string Action(Light l) => l switch
{
Light.Red => "Stop",
Light.Yellow => "Prepare",
Light.Green => "Go"
};
Adding guards with when
A guard clause filters a matching pattern by an additional boolean expression. It provides a focused way to handle exceptions to common shapes.
string Describe(int value) => value switch
{
0 => "Zero",
> 0 and <= 10 when value % 2 == 0 => "Small even",
> 0 and <= 10 => "Small odd",
_ => "Large"
};
Guards help keep pattern bodies free from extra condition code because the check sits directly with the pattern itself.
Designing with patterns in mind
Pattern matching works best when the surrounding types lean into structure. Records, deconstructors, and explicit shapes make patterns more powerful and the code easier to follow. When you design classes and data flows with this in mind you create software that reads like a layered description of states and transitions.
Shaping your types for expressive patterns
Adding a Deconstruct method or using records can turn your types into well-shaped pattern targets. This lets callers inspect only the pieces they need while still preserving encapsulation.
class Range
{
public int Start { get; }
public int End { get; }
public Range(int start, int end)
{
Start = start;
End = end;
}
public void Deconstruct(out int start, out int end)
{
start = Start;
end = End;
}
}
string Classify(Range r) => r switch
{
(var s, var e) when s < e => "Forward",
(var s, var e) when s > e => "Backward",
_ => "Point"
};
Designing your data this way builds a steady rhythm across your code where each pattern tells a small story about shape and intent.