<< Home

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());
}
💡 When a type pattern introduces a variable you can use it immediately within that scope which keeps branching code compact and descriptive.

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"
};
⚠️ Relational patterns depend on the underlying type supporting comparison. For custom types consider implementing comparison interfaces or using property patterns that examine sortable members.

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"
};
💡 Property patterns shine when matching nested types. You can walk the structure layer by layer without deeply nested 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"
};
⚠️ List patterns depend on the input type supporting length or count semantics. Arrays and many .NET collections work out of the box but custom types may require specific interfaces.

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"
};
💡 If you see a compiler warning about non-exhaustive patterns the fix often involves adding a fallback case or expanding your pattern set to cover all known shapes.

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.