Pattern Matching
|
This section documents C# 14 on .NET 10 (LTS), as published at learn.microsoft.com/dotnet/csharp, which is the reference these pages are written and verified against. Features introduced by C# 15 / .NET 11 are still in preview and are always flagged as such — never presented as baseline. This content was generated with the assistance of AI and should be verified against learn.microsoft.com before being relied on in production. This section’s bibliography lists the reference material consulted while preparing these pages. |
A pattern tests whether a value has a particular shape and, when it does, pulls the interesting parts out — in a single expression. Pattern matching turned C# from a language where "check the type, cast, read a field" took four lines into one where it takes a clause. It grew feature by feature from C# 7 to C# 14 and is now the idiomatic way to write data-driven branching.
Where Patterns Appear
Patterns are usable in exactly three places: the is operator, switch statement labels, and switch
expression arms.
public static class WherePatternsAppear
{
public static string Describe(object value)
{
if (value is int count && count > 0) // 1. the `is` operator
{
return $"positive int {count}";
}
switch (value) // 2. a switch statement
{
case string { Length: 0 }:
return "empty string";
case string s:
return $"string of {s.Length}";
}
return value switch // 3. a switch expression
{
double d => $"double {d}",
null => "null",
_ => value.GetType().Name,
};
}
}
The Pattern Forms
Declaration and Type Patterns
The oldest form (C# 7): test the run-time type and, optionally, bind the result to a new variable.
public static class TypePatterns
{
public static double Area(object shape) => shape switch
{
Circle c => Math.PI * c.Radius * c.Radius, // declaration pattern: test + bind
Square => 1.0, // type pattern: test only, no binding
_ => 0.0,
};
}
public sealed class Circle
{
public double Radius { get; init; }
}
public sealed class Square
{
public double Side { get; init; }
}
A declaration pattern never matches null, which is what makes if (o is string s) safer than a cast.
Constant Patterns
Match against any constant expression — a literal, an enum member, a const, or null:
public static class ConstantPatterns
{
public static string Http(int status) => status switch
{
200 => "OK",
404 => "Not Found",
500 => "Server Error",
_ => "Other",
};
public static bool IsMissing(string? name) => name is null; // null is a constant pattern
public static bool IsPresent(string? name) => name is not null; // and negates cleanly
}
is null compares with == semantics without being hijacked by an overloaded == operator — which is why
the guidelines prefer is null over == null for reference types.
Relational Patterns (C# 9)
Compare with <, >, <=, >= against a constant:
public static class RelationalPatterns
{
public static string Classify(int temperature) => temperature switch
{
< 0 => "freezing",
>= 0 and < 15 => "cold",
>= 15 and < 25 => "mild",
_ => "hot",
};
}
Logical Patterns: and, or, not (C# 9)
Patterns compose. not binds tightest, then and, then or:
public static class LogicalPatterns
{
public static bool IsLetter(char c) => c is (>= 'a' and <= 'z') or (>= 'A' and <= 'Z');
public static bool IsVowel(char c) => char.ToLowerInvariant(c) is 'a' or 'e' or 'i' or 'o' or 'u';
public static bool IsRealNumber(double d) => d is not (double.NaN or double.PositiveInfinity
or double.NegativeInfinity);
public static string Handle(object o) => o switch
{
string or char => "text-ish",
int or long or short or byte => "integral",
not null => "something else",
null => "nothing",
};
}
Note that these are pattern operators, not the boolean &&/||/! operators: they combine patterns, and
the left operand of and/or is the same input value, not a separate expression.
Property Patterns (C# 8)
Match on the values of members, nesting as deep as needed:
public sealed record Address(string City, string Country, string PostCode);
public sealed record Order(Address ShipTo, decimal Total, int ItemCount);
public static class PropertyPatterns
{
public static decimal Shipping(Order order) => order switch
{
{ ShipTo.Country: "ES", Total: >= 50m } => 0m, // extended property pattern (C# 10)
{ ShipTo.Country: "ES" } => 4.95m,
{ ShipTo: { Country: "PT" or "FR" }, ItemCount: <= 3 } => 9.95m, // nested, pre-C# 10 form
_ => 19.95m,
};
// Binding inside a property pattern.
public static string Label(Order order) =>
order is { ShipTo.City: { Length: > 0 } city, Total: var total }
? $"{city}: {total:C}"
: "unlabelled";
}
The ShipTo.Country: form (C# 10) flattens what used to be ShipTo: { Country: … }. Both still compile; the
flattened form reads better beyond one level.
Positional Patterns
Any type with a Deconstruct method — every positional record, every tuple — can be matched positionally:
public readonly record struct Point(int X, int Y);
public static class PositionalPatterns
{
public static string Quadrant(Point p) => p switch
{
(0, 0) => "origin",
(var x, 0) => x > 0 ? "positive x-axis" : "negative x-axis",
(0, var y) => y > 0 ? "positive y-axis" : "negative y-axis",
( > 0, > 0) => "I",
( < 0, > 0) => "II",
( < 0, < 0) => "III",
_ => "IV",
};
// Positional and property patterns mix freely.
public static bool IsFarRight(Point p) => p is ( > 100, _) { Y: >= 0 };
}
Tuple Patterns
Switching on several values at once is the canonical use — no artificial nesting required:
public static class TuplePatterns
{
public static string RockPaperScissors(string first, string second) => (first, second) switch
{
("rock", "paper") => "second wins",
("rock", "scissors") => "first wins",
("paper", "rock") => "first wins",
("paper", "scissors") => "second wins",
("scissors", "rock") => "second wins",
("scissors", "paper") => "first wins",
(var a, var b) when a == b => "tie",
_ => "invalid",
};
}
var and Discard Patterns
var matches anything — including null — and binds it, which makes it useful for computing an intermediate
in a guard. _ matches anything and binds nothing.
public static class VarAndDiscard
{
public static string Bucket(IEnumerable<int> numbers) => numbers.Sum() switch
{
var total when total < 0 => "negative",
0 => "zero",
var total when total < 100 => $"small ({total})",
_ => "large",
};
}
Do not confuse the discard pattern with the discard in a switch expression’s final arm — syntactically
they are the same token, and in both cases it means "anything".
List and Slice Patterns (C# 11)
Match a sequence by its length and the shape of its elements. .. is the slice pattern, matching zero or more
elements and optionally binding them:
public static class ListPatterns
{
public static string Describe(int[] values) => values switch
{
[] => "empty",
[var only] => $"one element: {only}",
[var first, var second] => $"two: {first}, {second}",
[1, 2, ..] => "starts 1, 2",
[.., 9] => "ends with 9",
[var head, .. var middle, var tail] => $"{head} … ({middle.Length} inner) … {tail}",
};
// Slice patterns work on any countable, sliceable type -- arrays, List<T>, spans, strings.
public static bool IsCommand(ReadOnlySpan<char> text) => text is ['/', .. var rest] && rest.Length > 0;
}
A list pattern requires the type to be countable and indexable (Length/Count plus an indexer); a slice
pattern additionally requires it to be sliceable (an indexer taking Range, or a Slice method).
switch Statements versus switch Expressions
The statement form executes; the expression form produces a value. The expression form is preferred whenever every arm yields a result:
public static class SwitchForms
{
// Statement: side effects, multiple labels per section, explicit break.
public static void Log(int severity)
{
switch (severity)
{
case 0:
case 1:
Console.WriteLine("informational");
break;
case >= 2 and < 5:
Console.WriteLine("warning");
break;
default:
Console.WriteLine("error");
break;
}
}
// Expression: one value out, commas not breaks, `_` not `default`.
public static string Name(int severity) => severity switch
{
0 or 1 => "informational",
>= 2 and < 5 => "warning",
_ => "error",
};
}
Arms are tested top to bottom, first match wins. Order therefore carries meaning: a broad pattern placed early shadows the narrower ones after it, and the compiler reports that as an error ("the switch arm has already been handled").
matches?"} A1 -->|no| A2{"arm 2 pattern
matches?"} A1 -->|yes| G1{"case guard
when clause?"} G1 -->|"absent, or true"| R1["evaluate arm 1's
expression -- done"] G1 -->|false| A2 A2 -->|no| A3{"…remaining arms,
in source order"} A2 -->|yes| G2{"case guard?"} G2 -->|"absent, or true"| R2["evaluate arm 2's
expression -- done"] G2 -->|false| A3 A3 -->|"a later arm matches"| RN["evaluate that arm -- done"] A3 -->|"no arm matches"| EX{"is there a
_ discard arm?"} EX -->|yes| RD["evaluate the discard arm -- done"] EX -->|no| THROW["SwitchExpressionException
thrown at run time
(the compiler warned:
'not exhaustive')"] style THROW fill:#fdeaea,stroke:#b03030 style R1 fill:#eef6f0,stroke:#6fa383 style R2 fill:#eef6f0,stroke:#6fa383 style RN fill:#eef6f0,stroke:#6fa383 style RD fill:#eef6f0,stroke:#6fa383
Case Guards: when
A when clause adds an arbitrary boolean condition on top of a pattern. Use it for anything a pattern cannot
express — relationships between the bound values, method calls, range checks against a variable:
public static class CaseGuards
{
public static string Compare(Point p) => p switch
{
var (x, y) when x == y => "on the diagonal",
var (x, y) when x == -y => "on the anti-diagonal",
( > 0, > 0) => "quadrant I",
_ => "elsewhere",
};
public static string Sizing(string text, int limit) => text switch
{
{ Length: 0 } => "empty",
_ when text.Length > limit => "too long", // limit is a variable -- not a constant pattern
_ => "fine",
};
}
A guard is checked only if the pattern already matched, and a failed guard falls through to the next arm — exactly as the diagram above shows.
Exhaustiveness
A switch expression must handle every possible input. When the compiler cannot prove it does, it emits
warning CS8509 and, if an unhandled value does arrive at run time, the expression throws
SwitchExpressionException.
public enum Direction { North, South, East, West }
public static class Exhaustiveness
{
// Exhaustive over the declared members -- but an enum can legally hold any underlying value,
// e.g. (Direction)99, so the compiler still wants a fallback.
public static string ToArrow(Direction d) => d switch
{
Direction.North => "↑",
Direction.South => "↓",
Direction.East => "→",
Direction.West => "←",
_ => throw new ArgumentOutOfRangeException(nameof(d), d, "unknown direction"),
};
// For bool the compiler CAN prove exhaustiveness -- no fallback needed.
public static string YesNo(bool b) => b switch
{
true => "yes",
false => "no",
};
}
Throwing in the fallback arm, rather than returning a default, is the recommended habit: adding a new enum member then fails loudly at the one place that forgot it, instead of silently producing a wrong answer.
For a class hierarchy the compiler is even more conservative — any type could be derived elsewhere, so an
object-typed switch is never exhaustive without a _ arm. The two C# 15 preview features below exist to close
exactly that gap.
Union Types and Closed Hierarchies (C# 15 preview)
|
Preview feature — C# 15 / .NET 11
The two features in this section require a .NET 11 preview SDK and |
A closed hierarchy tells the compiler that the complete set of derived types is known within the declaring
assembly, so a switch covering them all is provably exhaustive and needs no _ arm:
// The compiler knows Success and Failure are the only possibilities.
public closed record Result;
public sealed record Success(string Payload) : Result;
public sealed record Failure(string Reason) : Result;
public static class ClosedSwitch
{
// No `_` arm, and no CS8509 warning: the hierarchy is closed.
public static string Render(Result result) => result switch
{
Success(var payload) => $"ok: {payload}",
Failure(var reason) => $"failed: {reason}",
};
}
Union types express the same idea without a common base type — an ad-hoc "one of these" over unrelated types:
// A union of two existing, unrelated types.
public union Payment(CardPayment, BankTransfer);
public sealed record CardPayment(string Last4, decimal Amount);
public sealed record BankTransfer(string Iban, decimal Amount);
public static class UnionSwitch
{
public static decimal AmountOf(Payment payment) => payment switch
{
CardPayment card => card.Amount,
BankTransfer transfer => transfer.Amount,
};
}
Until these ship, the C# 14 way to approximate a closed hierarchy is an abstract base with a private
constructor — which stops derivation outside the assembly but still does not satisfy the exhaustiveness
analysis, so a throwing _ arm remains necessary:
public abstract record Shape
{
private Shape() { } // only nested types can derive
public sealed record Circle(double Radius) : Shape;
public sealed record Rectangle(double Width, double Height) : Shape;
}
public static class ApproximatedClosedHierarchy
{
public static double Area(Shape shape) => shape switch
{
Shape.Circle c => Math.PI * c.Radius * c.Radius,
Shape.Rectangle r => r.Width * r.Height,
_ => throw new ArgumentOutOfRangeException(nameof(shape)),
};
}
Data-Driven Algorithms
Pattern matching earns its keep when a whole algorithm becomes one table of shapes. The canonical tutorial — computing a toll from the vehicle, its occupancy and the time of day — reduces nested if forests to arms
that read like the specification they implement:
public abstract class Vehicle;
public sealed class Car : Vehicle
{
public int Passengers { get; init; }
}
public sealed class Taxi : Vehicle
{
public int Fares { get; init; }
}
public sealed class Bus : Vehicle
{
public int Capacity { get; init; }
public int Riders { get; init; }
}
public sealed class DeliveryTruck : Vehicle
{
public int GrossWeightClass { get; init; }
}
public static class TollCalculator
{
public static decimal BaseToll(Vehicle vehicle) => vehicle switch
{
Car { Passengers: 0 } => 2.00m + 0.50m,
Car { Passengers: 1 } => 2.00m,
Car { Passengers: 2 } => 2.00m - 0.50m,
Car => 2.00m - 1.00m,
Taxi { Fares: 0 } => 3.50m + 1.00m,
Taxi { Fares: 1 } => 3.50m,
Taxi => 3.50m - 1.00m,
Bus b when (double)b.Riders / b.Capacity < 0.50 => 5.00m + 2.00m,
Bus b when (double)b.Riders / b.Capacity > 0.90 => 5.00m - 1.00m,
Bus => 5.00m,
DeliveryTruck { GrossWeightClass: > 5000 } => 10.00m + 5.00m,
DeliveryTruck { GrossWeightClass: < 3000 } => 10.00m - 2.00m,
DeliveryTruck => 10.00m,
null => throw new ArgumentNullException(nameof(vehicle)),
_ => throw new ArgumentException("unknown vehicle type", nameof(vehicle)),
};
// Peak-hour multipliers are a tuple switch over independent inputs.
public static decimal PeakMultiplier(DayOfWeek day, int hour, bool inbound) =>
(day, hour, inbound) switch
{
(DayOfWeek.Saturday or DayOfWeek.Sunday, _, _) => 1.0m,
(_, >= 6 and < 10, true) => 2.0m,
(_, >= 16 and < 20, false) => 2.0m,
(_, >= 10 and < 16, _) => 1.5m,
_ => 1.0m,
};
}
Practical Guidance
-
Reach for a
switchexpression when every branch produces a value; keep the statement form for side effects. -
Put the most specific arms first — ordering is semantic.
-
Prefer a pattern over a
whenguard where one exists: patterns participate in exhaustiveness analysis, guards do not (an arm with a guard never counts as covering its pattern). -
Always give a class-hierarchy switch a throwing fallback, so new subtypes surface immediately.
-
Do not let arms grow bodies. When an arm needs more than an expression, call a well-named method from it.
See Also
-
Control Flow — the
switchstatement in its non-pattern-matching role. -
Records —
Deconstructis what makes positional patterns work. -
Enums — switching over enums and why exhaustiveness is approximate there.
-
Collections and Iterators — the countable and sliceable requirements behind list patterns.
-
C# Versions and What’s New — which release introduced which pattern form.
References
-
Microsoft Learn — Tutorial: use pattern matching to build type-driven and data-driven algorithms.
-
github.com/dotnet/csharplang — the union types and closed hierarchies feature proposals.