~/handys11
Browse the docs

dotnet/C# Language

Pattern matching

basics

TL;DR

// Switch expression + patterns: modern C# control flow.
static string Describe(Shape shape) => shape switch
{
    Circle { Radius: 0 }         => "a point",
    Circle c when c.Radius > 100 => "a huge circle",
    Circle                       => "a circle",
    Rectangle { W: var w, H: var h } when w == h => "a square",
    null                         => "nothing",
    _                            => "something else",
};

// 'is' patterns introduce scoped variables, combine with and/or/not.
if (response is { StatusCode: >= 200 and < 300, Body.Length: > 0 })
    Process(response.Body);

// List patterns (C# 11+).
var summary = numbers switch
{
    []                        => "empty",
    [var only]                => $"just {only}",
    [var first, .., var last] => $"{first}{last}",
};

How it works

C# has one pattern grammar shared by is, switch statements, switch expressions and when guards. Knowing the catalogue means every context uses the same vocabulary:

  • Constantcase 0, case "GET", case null — compares with == or reference equality.
  • Declaration and typeis Circle c binds a typed local only when the match succeeds; a bare Circle pattern checks the type without binding.
  • Property{ Radius: 0 } recurses into members, nesting as deep as the object graph allows, including nested member access like Body.Length.
  • PositionalCircle(var radius) destructures through Deconstruct, the same method tuple deconstruction (var (x, y) = point;) relies on.
  • Relational> 100, >= 200 and < 300 — compares with <, <=, >, >= against a constant.
  • Logical combinatorsand, or, not compose any of the above, including chaining relational patterns into a range check.
  • var — always matches, binds the value under any name; useful to capture a sub-expression for a when clause.
  • Discard_ always matches and binds nothing; the conventional catch-all arm.
  • List and slice[], [var first, .., var last] — matches array or list length and shape, with .. as a slice for “everything in between.”

A switch statement and a switch expression compile the same patterns but differ in shape: the statement is a series of case blocks, each exited explicitly with break, return, or goto case, with an optional default: catch-all, while the expression is a single value produced from an exhaustive set of arms — closer to a function than a control-flow construct. Reach for the expression whenever every arm just produces a value.

Under the hood, the compiler doesn’t test each arm top to bottom like a naive if/else if chain. It builds a decision DAG: type checks and constant comparisons that appear in more than one arm are evaluated once and shared, tests are reordered for efficiency, and arms that can never be reached — because an earlier, broader pattern already covers them — are rejected with compile error CS8510; unlike non-exhaustiveness, a dead arm doesn’t even compile. The compiler also runs exhaustiveness analysis on switch expressions over enums and sealed hierarchies, warning when a value exists that no arm handles.

Gotchas

  • An unmatched switch expression throws at runtime. Exhaustiveness analysis is a warning, not a compile error — ship a build with warnings ignored and a value that falls through every arm throws SwitchExpressionException. Always include a _ arm unless you have verified exhaustiveness yourself.
  • is T t is false for null. A type pattern never matches null, so not null and type patterns compose safely — obj is not null and Circle c doesn’t need a separate null check first.
  • Property patterns invoke getters. Matching { Radius: 0 } calls the Radius getter — a side-effecting or expensive getter runs as part of matching, not just when an arm you expect actually executes.
  • Positional patterns need Deconstruct. Records synthesize one for free; plain classes and structs don’t unless you write it yourself — attempting a positional pattern on a type without Deconstruct is a compile error, not a runtime miss.
  • when guards escape exhaustiveness analysis. The compiler can’t reason about an arbitrary boolean expression, so any arm with a when clause makes the whole switch look non-exhaustive from its perspective. Prefer relational patterns (> 100, >= 200 and < 300) over when when the condition can be expressed that way.

Further reading